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