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