Skip to main content

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