Skip to main content

alibi_plugins/
oauth_proxy.rs

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