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