1use super::oauth::handlers::{
4 OAuthSignInError, callback_binding_error, complete_link_social, create_account_cookie_headers,
5 fetch_user_info_from_provider, grant_id_token_verified, 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.provider(provider_id) else {
462 return Ok(Some(error_redirect(
463 &error_url,
464 "oauth_provider_not_found",
465 None,
466 )?));
467 };
468 if let Some(error) = callback_binding_error(
470 provider,
471 params.get("iss").map(String::as_str),
472 state.id_token_nonce.as_deref(),
473 ) {
474 return Ok(Some(error_redirect(&error_url, error, None)?));
475 }
476 let Ok(tokens) = validate_authorization_code_via_provider(
477 provider,
478 code,
479 &format!(
480 "{}{}",
481 auth_base(ctx),
482 oauth_callback_path(provider_id, provider)
483 ),
484 provider
485 .authorization
486 .as_ref()
487 .is_none_or(|policy| policy.pkce)
488 .then_some(state.code_verifier.as_str()),
489 params.get("device_id").map(String::as_str),
490 )
491 .await
492 else {
493 return Ok(Some(error_redirect(&error_url, "invalid_code", None)?));
494 };
495 if !grant_id_token_verified(provider, &tokens, state.id_token_nonce.as_deref()).await {
496 return Ok(Some(error_redirect(
497 &error_url,
498 "unable_to_get_user_info",
499 None,
500 )?));
501 }
502 let mut info = fetch_user_info_from_provider(
505 provider,
506 OAuthUserInfoRequest {
507 token_type: tokens.token_type.clone(),
508 access_token: tokens.access_token.clone(),
509 refresh_token: tokens.refresh_token.clone(),
510 access_token_expires_at: tokens.access_token_expires_at,
511 refresh_token_expires_at: tokens.refresh_token_expires_at,
512 scopes: tokens.scopes.clone(),
513 id_token: tokens.id_token.clone(),
514 raw: tokens.raw.clone(),
515 user: parse_callback_user_payload(params.get("user").map(String::as_str)),
516 },
517 )
518 .await?;
519 if resolve_oauth_account_key(provider, &tokens, &mut info)
520 .await
521 .is_err()
522 {
523 return Ok(Some(error_redirect(
524 &error_url,
525 "unable_to_get_user_info",
526 None,
527 )?));
528 }
529 if info.user.email.is_empty() {
530 return Ok(Some(error_redirect(&error_url, "email_not_found", None)?));
531 }
532 let mut callback = url::Url::parse(&state.callback_url)
533 .map_err(|_error| AuthError::internal("Invalid proxy callback URL"))?;
534 let callback_url = callback
535 .query_pairs()
536 .find(|(key, _)| key == "callbackURL")
537 .map_or_else(
538 || state.callback_url.clone(),
539 |(_, value)| value.into_owned(),
540 );
541 let payload = ProxyPayload {
542 user_info: ProxyUser {
543 id: info.user.id.clone(),
544 email: info.user.email,
545 name: info.user.name.unwrap_or_default(),
546 image: info.user.image,
547 additional_fields: Map::default(),
548 email_verified: info.user.email_verified,
549 },
550 profile: Some(info.data),
551 scopes: Some(tokens.scopes.clone()),
552 account: ProxyAccount {
553 provider_id: provider_id.to_owned(),
554 account_id: info.user.id,
555 access_token: tokens.access_token,
556 refresh_token: tokens.refresh_token,
557 id_token: tokens.id_token,
558 access_token_expires_at: tokens.access_token_expires_at,
559 refresh_token_expires_at: tokens.refresh_token_expires_at,
560 scope: Some(tokens.scopes.join(",")),
561 },
562 state: package.state,
563 callback_url,
564 new_user_url: state.new_user_url,
565 error_url: state.error_url,
566 disable_sign_up: if provider.disable_implicit_sign_up
567 && !state.request_sign_up.unwrap_or(false)
568 {
569 Some(true)
570 } else {
571 oauth_disable_sign_up_option(provider)
572 },
573 timestamp: Utc::now().timestamp_millis() as f64,
574 };
575 _ = callback.query_pairs_mut().append_pair(
576 "profile",
577 &self.encrypt(
578 &alibi_core::utils::json::to_string(&payload)?,
579 ctx,
580 EncryptionPurpose::ProxyProfile,
581 )?,
582 );
583 Ok(Some(redirect(callback.as_str())))
584 }
585 #[expect(
586 clippy::as_conversions,
587 clippy::cast_precision_loss,
588 reason = "Preserve JavaScript Number rounding at the compatibility boundary"
589 )]
590 #[expect(
591 clippy::too_many_lines,
592 reason = "Keep proxy state consumption, identity linking, and session issuance in protocol order"
593 )]
594 async fn complete<S: AuthSchema>(
595 &self,
596 provider_id: Option<&str>,
597 query: &std::collections::HashMap<String, String>,
598 req: &impl alibi_core::session::SessionRequest,
599 ctx: &AuthContext<S>,
600 ) -> AuthResult<AuthResponse> {
601 let callback = query
602 .get("callbackURL")
603 .ok_or_else(|| AuthError::bad_request("Missing callbackURL"))?;
604 if !ctx.config.current_origin_check_disabled()
605 && !ctx.config.is_redirect_target_trusted(callback)
606 {
607 return Err(AuthError::forbidden("Invalid callbackURL"));
608 }
609 let default_error = ctx
610 .config
611 .api_error_url
612 .clone()
613 .filter(|value| !value.is_empty())
614 .unwrap_or_else(|| {
615 format!(
616 "{}/api/auth/error",
617 ctx.config.base_url.trim_end_matches('/')
618 )
619 });
620 let Some(profile) = query.get("profile").filter(|profile| !profile.is_empty()) else {
621 return error_redirect(&default_error, "missing_profile", None);
622 };
623 let Ok(plain) = self.decrypt(profile, ctx, EncryptionPurpose::ProxyProfile) else {
624 return error_redirect(&default_error, "invalid_profile", None);
625 };
626 let Ok(raw) = alibi_core::utils::json::from_slice::<Value>(plain.as_bytes()) else {
627 return error_redirect(&default_error, "invalid_payload", None);
628 };
629 if raw.get("profile").is_some_and(|value| !value.is_object())
630 || raw.get("scopes").is_some_and(|value| {
631 !value
632 .as_array()
633 .is_some_and(|items| items.iter().all(Value::is_string))
634 })
635 || ["newUserURL", "errorURL"]
636 .iter()
637 .any(|field| raw.get(*field).is_some_and(|value| !value.is_string()))
638 || raw
639 .get("disableSignUp")
640 .is_some_and(|value| !value.is_boolean())
641 {
642 return error_redirect(&default_error, "invalid_payload", None);
643 }
644 let Ok(payload) = alibi_core::utils::json::from_slice::<ProxyPayload>(plain.as_bytes())
645 else {
646 return error_redirect(&default_error, "invalid_payload", None);
647 };
648 if payload
649 .profile
650 .as_ref()
651 .is_some_and(|profile_2| !profile_2.is_object())
652 || !payload.timestamp.is_finite()
653 || payload.state.is_empty()
654 || payload.callback_url.is_empty()
655 {
656 return error_redirect(&default_error, "invalid_payload", None);
657 }
658 let error_url = payload
659 .error_url
660 .as_deref()
661 .filter(|value| !value.is_empty())
662 .unwrap_or(&default_error);
663 if provider_id.is_some_and(|id| id != payload.account.provider_id) {
664 return error_redirect(error_url, "provider_mismatch", None);
665 }
666 let age = (Utc::now().timestamp_millis() as f64 - payload.timestamp) / 1000.0;
667 if age > self.config.max_age_seconds || age < -10.0 {
668 return error_redirect(error_url, "payload_expired", None);
669 }
670 let cookie_name = state_cookie_name(&ctx.config);
671 let authenticated_cookie = get_cookie(req, &cookie_name);
672 let state: OAuthStatePayload = match ctx.config.account.store_state_strategy {
673 OAuthStateStrategy::Automatic | OAuthStateStrategy::Database => {
674 let Ok(Some(row)) = ctx
675 .verifications()
676 .find(&state_verification_identifier(&payload.state))
677 .await
678 else {
679 return error_redirect(error_url, "state_mismatch", None);
680 };
681 let Ok(state) = alibi_core::utils::json::from_slice(row.value()?.as_bytes()) else {
682 return error_redirect(error_url, "state_mismatch", None);
683 };
684 state
685 }
686 OAuthStateStrategy::Cookie => {
687 let Some(cookie) = authenticated_cookie.as_deref() else {
691 return error_redirect(error_url, "state_mismatch", None);
692 };
693 let Ok(state) = decode_cookie_state_value(&ctx.config, cookie) else {
694 return error_redirect(error_url, "state_mismatch", None);
695 };
696 if state
697 .additional_data
698 .get("oauthState")
699 .and_then(Value::as_str)
700 != Some(payload.state.as_str())
701 {
702 return error_redirect(error_url, "state_mismatch", None);
703 }
704 state
705 }
706 };
707 if state
708 .additional_data
709 .get("oauthState")
710 .is_some_and(|nonce| nonce.as_str() != Some(payload.state.as_str()))
711 {
712 return error_redirect(error_url, "state_mismatch", None);
713 }
714 let clear = alibi_core::utils::cookie_utils::create_clear_cookie(
715 &state_cookie_name(&ctx.config),
716 &ctx.config,
717 )?;
718 req.queue_response_header("Set-Cookie", clear);
719 if matches!(
720 ctx.config.account.store_state_strategy,
721 OAuthStateStrategy::Automatic | OAuthStateStrategy::Database
722 ) && ctx
723 .verifications()
724 .delete(&state_verification_identifier(&payload.state))
725 .await
726 .is_err()
727 {
728 return error_redirect(error_url, "state_mismatch", None);
729 }
730 if state.is_expired() {
731 return error_redirect(error_url, "state_mismatch", None);
732 }
733 let context = match ctx.config.account.store_state_strategy {
737 OAuthStateStrategy::Automatic | OAuthStateStrategy::Database => ctx
738 .config
739 .verification_secrets()
740 .find_map(|secret| verified_server_context(&state, &payload.state, secret)),
741 OAuthStateStrategy::Cookie => authenticated_cookie
742 .as_deref()
743 .and_then(|cookie| super::token_crypto::decryption_key(cookie, &ctx.config).ok())
744 .and_then(|secret| verified_server_context(&state, &payload.state, secret)),
745 };
746 if let Some(context) = context {
747 req.extensions()
748 .insert(RecoveredOAuthServerContext(context));
749 }
750 let user = OAuthUserInfo {
751 additional_fields: payload.user_info.additional_fields,
752 id: payload.account.account_id,
755 email: payload.user_info.email.to_lowercase(),
756 name: Some(payload.user_info.name),
757 image: payload.user_info.image,
758 email_verified: payload.user_info.email_verified,
759 };
760 let tokens = OAuthTokenSet {
761 access_token: payload.account.access_token,
762 refresh_token: payload.account.refresh_token,
763 id_token: payload.account.id_token,
764 access_token_expires_at: payload.account.access_token_expires_at,
765 refresh_token_expires_at: payload.account.refresh_token_expires_at,
766 scopes: payload.scopes.unwrap_or_else(|| {
767 payload
768 .account
769 .scope
770 .map(|scope| scope.split(',').map(String::from).collect())
771 .unwrap_or_default()
772 }),
773 ..Default::default()
774 };
775 let profile = payload.profile.unwrap_or(Value::Null);
776 if let Some(link) = state.link.as_ref() {
777 return match complete_link_social(
778 &payload.account.provider_id,
779 &user,
780 &profile,
781 &tokens,
782 link,
783 ctx,
784 )
785 .await
786 {
787 Ok(()) => Ok(redirect(&payload.callback_url)),
788 Err(error) => {
789 if error.is_ambiguous_account() {
790 return Ok(AuthResponse::new(500));
791 }
792 let (code, description) = match &error {
793 OAuthSignInError::Generic(message) => (message.clone(), None),
794 _ => error.redirect_parts(),
795 };
796 error_redirect(error_url, &code, description)
797 }
798 };
799 }
800 let user_output = raw
801 .get("userInfo")
802 .and_then(Value::as_object)
803 .map(|values| {
804 values
805 .iter()
806 .map(|(name, value)| (name.clone(), value.clone()))
807 .collect::<alibi_core::field_policy::FieldOutput>()
808 });
809 let outcome = match super::oauth::handlers::process_oauth_sign_in_with_output(
810 super::oauth::handlers::OAuthIdentity {
811 provider_name: &payload.account.provider_id,
812 user: &user,
813 profile: &profile,
814 },
815 &OAuthProcessPolicy {
816 callback_url: Some(payload.callback_url.clone()),
817 ..Default::default()
818 },
819 &tokens,
820 payload.disable_sign_up.unwrap_or(false),
821 &if req.endpoint_call().is_some() {
822 alibi_core::RequestMeta {
823 ip_address: Some(
824 ctx.config
825 .advanced
826 .ip_address
827 .resolve_ip(req.session_headers())
828 .unwrap_or_default(),
829 ),
830 user_agent: Some(
831 req.session_headers()
832 .get("user-agent")
833 .cloned()
834 .unwrap_or_default(),
835 ),
836 }
837 } else {
838 req.physical_request()
839 .map(alibi_core::RequestMeta::from_request)
840 .unwrap_or_default()
841 },
842 ctx,
843 (user_output.as_ref(), None),
844 )
845 .await
846 {
847 Ok(outcome) => outcome,
848 Err(error) => {
849 if error.is_ambiguous_account() {
850 return Ok(crate::oauth::handlers::ambiguous_account_sign_in_response(
851 ctx,
852 ));
853 }
854 if let OAuthSignInError::SessionAuth(error) = error {
855 return match error {
856 AuthError::SessionCreationCancelled => {
857 error_redirect(error_url, "unable_to_create_session", None)
858 }
859 AuthError::Api {
860 code: Some(code),
861 message,
862 ..
863 } => error_redirect(error_url, &code, Some(&message)),
864 AuthError::Upstream { code, message, .. } => {
865 error_redirect(error_url, code, Some(message))
866 }
867 error
868 if error.status_code() < 500
869 || matches!(error, AuthError::Api { .. }) =>
870 {
871 Err(error)
872 }
873 _ => {
874 _ = req.take_response_headers();
878 req.extensions()
879 .insert(OAuthProxyUnhandledError(AtomicBool::new(true)));
880 Ok(AuthResponse::new(500))
881 }
882 };
883 }
884 let (code, description) = error.redirect_parts();
885 return error_redirect(error_url, &code, description);
886 }
887 };
888 let target = if outcome.is_register {
889 payload
890 .new_user_url
891 .as_deref()
892 .filter(|value| !value.is_empty())
893 .unwrap_or(&payload.callback_url)
894 } else {
895 &payload.callback_url
896 };
897 let dont_remember = ctx.session_manager().has_dont_remember_cookie(req);
898 let mut response = redirect(target).with_appended_header(
899 "Set-Cookie",
900 alibi_core::utils::cookie_utils::create_session_cookie_with_max_age(
901 Some(outcome.session.token()),
902 (!dont_remember).then(|| ctx.config.session.expires_in.num_seconds()),
903 &ctx.config,
904 )?,
905 );
906 if dont_remember {
907 response.headers.append(
908 "Set-Cookie",
909 alibi_core::utils::cookie_utils::create_session_like_cookie(
910 &alibi_core::utils::cookie_utils::related_cookie_name(
911 &ctx.config,
912 "dont_remember",
913 ),
914 &alibi_core::utils::cookie_utils::sign_cookie_value(
915 "true",
916 ctx.config.current_secret(),
917 ),
918 None,
919 &ctx.config,
920 )?,
921 );
922 }
923 if let Some(account) = outcome.account_cookie.as_ref() {
924 for header in create_account_cookie_headers(&ctx.config, account, req)? {
925 response.headers.append("Set-Cookie", header);
926 }
927 }
928 Ok(response)
929 }
930}
931
932#[async_trait]
933impl<S: AuthSchema> AuthPlugin<S> for OAuthProxyPlugin {
934 route_openapi_metadata!(S);
935
936 fn name(&self) -> &'static str {
937 "oauth-proxy"
938 }
939 fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
940 vec![
941 crate::endpoint::definition(
942 "oAuthProxy",
943 "oauthProxyCallback",
944 Some("/oauth-proxy-callback"),
945 HttpMethod::Get,
946 ),
947 crate::endpoint::definition(
948 "oAuthProxyCompletion",
949 "oauthProxyCompletion",
950 Some("/callback/:id/oauth-proxy"),
951 HttpMethod::Get,
952 ),
953 ]
954 }
955 fn validate_endpoint(
956 &self,
957 call: &alibi_core::endpoint::EndpointCall,
958 _ctx: &AuthContext<S>,
959 ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
960 Ok(alibi_core::endpoint::EndpointInput {
961 body: call.body().cloned(),
962 query: Some(crate::endpoint::validate_fields(
963 call.query(),
964 "query",
965 &[
966 crate::authentication_helpers::JsonField::string("callbackURL", true),
967 crate::authentication_helpers::JsonField::string("profile", false),
968 ],
969 )?),
970 })
971 }
972 async fn on_endpoint(
973 &self,
974 call: &alibi_core::endpoint::EndpointCall,
975 ctx: &AuthContext<S>,
976 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
977 let query: std::collections::HashMap<String, String> = alibi_core::utils::json::from_value(
978 call.query()
979 .cloned()
980 .unwrap_or(alibi_core::utils::json::JsValue::Null),
981 )?;
982 let provider = if call.operation_id() == "oauthProxyCompletion" {
983 Some(
984 call.params()
985 .and_then(|params| params.get("id"))
986 .map(alibi_core::utils::json::JsValue::coerce_string)
987 .transpose()
988 .map_err(AuthError::internal)?
989 .ok_or_else(|| {
990 AuthError::internal("Missing OAuth proxy provider parameters")
991 })?,
992 )
993 } else {
994 None
995 };
996 let response = Box::pin(self.complete(provider.as_deref(), &query, call, ctx)).await?;
997 let mut output = if response.status == 302 {
998 alibi_core::endpoint::EndpointResponse::redirect(
999 response
1000 .headers
1001 .get("location")
1002 .cloned()
1003 .unwrap_or_default(),
1004 )
1005 } else {
1006 alibi_core::endpoint::EndpointResponse::error(AuthError::Api {
1007 status: response.status,
1008 code: None,
1009 message: String::new(),
1010 })
1011 .without_error_body()
1012 };
1013 if response.status == 302 {
1014 output = output.with_header("Content-Type", "application/json");
1015 }
1016 for (name, value) in response.headers {
1017 if !name.eq_ignore_ascii_case("content-type") {
1018 output = output.with_header(name, value);
1019 }
1020 }
1021 Ok(output)
1022 }
1023 fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
1024 vec![self]
1025 }
1026 fn routes(&self) -> Vec<AuthRoute> {
1027 vec![
1028 AuthRoute::get("/callback/{provider}/oauth-proxy", "oauth_proxy"),
1029 AuthRoute::get("/oauth-proxy-callback", "oauth_proxy_legacy"),
1030 ]
1031 }
1032 async fn before_request(
1033 &self,
1034 req: &AuthRequest,
1035 ctx: &AuthContext<S>,
1036 ) -> AuthResult<Option<BeforeRequestAction>> {
1037 if let Some(provider) = regular_provider(req.path()) {
1038 return Ok(self
1039 .forward(provider, req, ctx)
1040 .await?
1041 .map(BeforeRequestAction::Respond));
1042 }
1043 if *req.method() != HttpMethod::Post
1044 || !(req.path().starts_with("/sign-in/social") || req.path() == "/link-social")
1045 {
1046 return Ok(None);
1047 }
1048 if let Some(flow) =
1049 self.prepare_flow(Some(req), req.body_as_json::<Value>().ok().as_ref(), ctx)?
1050 {
1051 req.extensions().insert(flow);
1052 }
1053 Ok(None)
1054 }
1055 async fn on_request(
1056 &self,
1057 req: &AuthRequest,
1058 ctx: &AuthContext<S>,
1059 ) -> AuthResult<Option<AuthResponse>> {
1060 if *req.method() != HttpMethod::Get {
1061 return Ok(None);
1062 }
1063 if let Some(provider) = completion_provider(req.path()) {
1064 return Ok(Some(
1065 self.complete(Some(provider), &req.query, req, ctx).await?,
1066 ));
1067 }
1068 if req.path() == "/oauth-proxy-callback" {
1069 return Ok(Some(self.complete(None, &req.query, req, ctx).await?));
1070 }
1071 Ok(None)
1072 }
1073 async fn after_request(
1074 &self,
1075 req: &AuthRequest,
1076 ctx: &AuthContext<S>,
1077 response: AuthResponse,
1078 ) -> AuthResult<AuthResponse> {
1079 self.wrap_state(req, ctx, response)
1080 }
1081}
1082
1083impl std::fmt::Debug for OAuthProxyPlugin {
1084 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1085 f.debug_struct("OAuthProxyPlugin").finish_non_exhaustive()
1086 }
1087}
1088
1089#[doc(hidden)]
1092#[must_use]
1093pub fn take_unhandled_error(req: &AuthRequest) -> bool {
1094 req.extensions()
1095 .get::<OAuthProxyUnhandledError>()
1096 .is_some_and(|marker| marker.0.swap(false, Ordering::Relaxed))
1097}
1098
1099fn auth_base<S: AuthSchema>(ctx: &AuthContext<S>) -> String {
1100 format!(
1101 "{}{}",
1102 ctx.config.base_url.trim_end_matches('/'),
1103 ctx.config.base_path
1104 )
1105}
1106
1107fn redirect(target: &str) -> AuthResponse {
1108 AuthResponse::new(302)
1109 .with_header("content-type", "application/json")
1110 .with_header("Location", target)
1111}
1112
1113fn error_redirect(base: &str, code: &str, description: Option<&str>) -> AuthResult<AuthResponse> {
1114 let mut url =
1115 url::Url::parse(base).map_err(|_error| AuthError::internal("Invalid OAuth error URL"))?;
1116 _ = url.query_pairs_mut().append_pair("error", code);
1117 if let Some(message) = description {
1118 _ = url
1119 .query_pairs_mut()
1120 .append_pair("error_description", message);
1121 }
1122 Ok(redirect(url.as_str()))
1123}
1124
1125fn regular_provider(path: &str) -> Option<&str> {
1126 path.strip_prefix("/callback/")
1127 .filter(|id| !id.is_empty() && !id.contains('/'))
1128}
1129
1130fn completion_provider(path: &str) -> Option<&str> {
1131 path.strip_prefix("/callback/")?
1132 .strip_suffix("/oauth-proxy")
1133 .filter(|id| !id.is_empty() && !id.contains('/'))
1134}
1135
1136fn vendor_base_url() -> Option<String> {
1137 if let Ok(value) = std::env::var("VERCEL_URL")
1138 && !value.is_empty()
1139 {
1140 return Some(format!("https://{value}"));
1141 }
1142 [
1143 "NETLIFY_URL",
1144 "RENDER_URL",
1145 "AWS_LAMBDA_FUNCTION_NAME",
1146 "GOOGLE_CLOUD_FUNCTION_NAME",
1147 "AZURE_FUNCTION_NAME",
1148 ]
1149 .into_iter()
1150 .find_map(|name| std::env::var(name).ok().filter(|value| !value.is_empty()))
1151}
1152
1153#[async_trait]
1154impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for OAuthProxyPlugin {
1155 fn matches_before(
1156 &self,
1157 call: &alibi_core::endpoint::EndpointCall,
1158 _: &AuthContext<S>,
1159 ) -> AuthResult<bool> {
1160 Ok(call.path().is_some_and(|path| {
1161 path.starts_with("/sign-in/social") || path == "/link-social" || path == "/callback/:id"
1162 }))
1163 }
1164 async fn before(
1165 &self,
1166 call: &alibi_core::endpoint::EndpointCall,
1167 ctx: &AuthContext<S>,
1168 ) -> AuthResult<Option<alibi_core::endpoint::BeforeEndpointAction>> {
1169 if call.path() == Some("/callback/:id") {
1170 let mut merged = std::collections::HashMap::new();
1171 for input in [call.body(), call.query()].into_iter().flatten() {
1172 if let Some(object) = input.as_object() {
1173 for (key, value) in object {
1174 if !value.is_null() {
1175 _ = merged.insert(key.clone(), value.clone());
1176 }
1177 }
1178 }
1179 }
1180 let mut params = std::collections::HashMap::new();
1181 for (key, value) in merged {
1182 if let Some(value) = value.as_str() {
1183 _ = params.insert(key, value.to_owned());
1184 } else if [
1185 "state",
1186 "code",
1187 "error",
1188 "error_description",
1189 "device_id",
1190 "user",
1191 "iss",
1192 ]
1193 .contains(&key.as_str())
1194 {
1195 return Ok(None);
1196 }
1197 }
1198 let Some(provider) = call
1199 .params()
1200 .and_then(|params| params.get("id"))
1201 .and_then(alibi_core::utils::json::JsValue::as_str)
1202 else {
1203 return Ok(None);
1204 };
1205 if let Some(wire) = self.forward_params(provider, ¶ms, ctx).await? {
1206 let mut response = alibi_core::endpoint::EndpointResponse::redirect(
1207 wire.headers.get("location").cloned().unwrap_or_default(),
1208 );
1209 for (name, value) in wire.headers {
1210 if !name.eq_ignore_ascii_case("content-type") {
1211 response = response.with_header(name, value);
1212 }
1213 }
1214 return Ok(Some(alibi_core::endpoint::BeforeEndpointAction::Reject(
1215 response,
1216 )));
1217 }
1218 return Ok(None);
1219 }
1220 let body = call
1221 .body()
1222 .map(alibi_core::utils::json::to_value)
1223 .transpose()?;
1224 if let Some(flow) = self.prepare_flow(call.request(), body.as_ref(), ctx)? {
1225 call.extensions().insert(flow);
1226 }
1227 Ok(None)
1228 }
1229 fn matches_after(
1230 &self,
1231 call: &alibi_core::endpoint::EndpointCall,
1232 _: &AuthContext<S>,
1233 _: &alibi_core::endpoint::EndpointResponse,
1234 ) -> AuthResult<bool> {
1235 Ok(call.extensions().get::<IssuedProxyState>().is_some())
1236 }
1237 async fn after(
1238 &self,
1239 call: &alibi_core::endpoint::EndpointCall,
1240 ctx: &AuthContext<S>,
1241 mut response: alibi_core::endpoint::EndpointResponse,
1242 ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
1243 if response.result().is_ok() {
1244 let wire = self.wrap_state(call, ctx, response.to_response()?)?;
1245 let value = alibi_core::utils::json::from_slice(&wire.body)?;
1246 response.replace(value);
1247 if let Some(location) = wire.headers.get("location") {
1248 response = response.with_header("location", location.clone());
1249 }
1250 }
1251 Ok(response)
1252 }
1253}