Skip to main content

alibi_plugins/
oauth_popup.rs

1//! Popup OAuth entry and callback delivery, paired with the official popup client.
2use super::oauth::OAuthConfig;
3use alibi_core::error::codes;
4use alibi_core::utils::cookie_utils::{
5    create_clear_cookie, create_cookie, related_cookie_name, sign_cookie_value, verify_cookie_value,
6};
7use alibi_core::{
8    AuthContext, AuthError, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
9    AuthSchema, HttpMethod,
10};
11use async_trait::async_trait;
12use serde::{Deserialize, Serialize};
13use serde_json::{Value, json};
14
15const SCRIPT: &str = include_str!("oauth_popup_script.js");
16const CSP: &str = "default-src 'none'; script-src 'sha256-tIo2K8VBC9SnhvdZ+9GsGkQoZm+jm/JcxL+d+i8b8KQ='; base-uri 'none'";
17
18/// Starts a popup in its first-party auth context and delivers callback results
19/// to the trusted opener. Register alongside [`super::oauth::OAuthPlugin`] and
20/// [`super::BearerPlugin`] for embedded clients.
21#[derive(Clone, Debug, Default)]
22pub struct OAuthPopupPlugin;
23
24/// Provider and opener/redirect options for the public popup startup operation.
25#[derive(Debug, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase")]
27pub struct OAuthPopupStartInput {
28    pub provider: String,
29    pub popup_origin: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub popup_nonce: Option<String>,
32    #[serde(rename = "callbackURL", skip_serializing_if = "Option::is_none")]
33    pub callback_url: Option<String>,
34    #[serde(rename = "errorCallbackURL", skip_serializing_if = "Option::is_none")]
35    pub error_callback_url: Option<String>,
36    #[serde(rename = "newUserCallbackURL", skip_serializing_if = "Option::is_none")]
37    pub new_user_callback_url: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub scopes: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub request_sign_up: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub additional_data: Option<String>,
44}
45
46#[derive(Deserialize)]
47#[serde(rename_all = "camelCase")]
48struct Marker {
49    popup_origin: String,
50    #[serde(default)]
51    popup_nonce: String,
52}
53
54fn completion(origin: &str, message: &Value) -> AuthResult<AuthResponse> {
55    let mut payload = json!({"type":"better-auth:oauth-popup", "targetOrigin":origin});
56    if let (Some(payload), Some(message)) = (payload.as_object_mut(), message.as_object()) {
57        payload.extend(message.clone());
58    }
59    let data = serde_json::to_string(&payload)?
60        .replace('<', "\\u003c")
61        .replace('\u{2028}', "\\u2028")
62        .replace('\u{2029}', "\\u2029");
63    Ok(AuthResponse::html(200, format!("<!doctype html>\n<html>\n<head><meta charset=\"utf-8\"><title>Completing sign-in</title></head>\n<body>\n<script type=\"application/json\" id=\"better-auth-oauth-popup\">{data}</script>\n<script>{SCRIPT}</script>\n</body>\n</html>"))
64        .with_header("content-security-policy", CSP)
65        .with_header("cache-control", "no-store").with_header("pragma", "no-cache"))
66}
67
68impl OAuthPopupPlugin {
69    #[must_use]
70    pub const fn new() -> Self {
71        Self
72    }
73
74    /// Start popup authorization, returning a redirect error or an HTML response value.
75    /// # Errors
76    /// Returns an input serialization error.
77    pub fn oauth_popup_start_endpoint(
78        input: &OAuthPopupStartInput,
79    ) -> AuthResult<alibi_core::endpoint::ServerEndpoint<Value>> {
80        alibi_core::endpoint::ServerEndpoint::new("oauth-popup", "oauthPopupStart")
81            .with_query(input)
82    }
83
84    async fn start<S: AuthSchema>(
85        &self,
86        req: &AuthRequest,
87        ctx: &AuthContext<S>,
88    ) -> AuthResult<AuthResponse> {
89        self.start_core(req, ctx, &req.query).await
90    }
91
92    async fn start_core<S: AuthSchema>(
93        &self,
94        req: &impl alibi_core::session::SessionRequest,
95        ctx: &AuthContext<S>,
96        query: &std::collections::HashMap<String, String>,
97    ) -> AuthResult<AuthResponse> {
98        let origin = query
99            .get("popupOrigin")
100            .ok_or_else(|| AuthError::bad_request("popupOrigin is required"))?;
101        let provider = query
102            .get("provider")
103            .ok_or_else(|| AuthError::bad_request("provider is required"))?;
104        if !ctx.config.is_origin_trusted(origin) {
105            return Err(AuthError::Api {
106                status: 403,
107                code: Some(codes::INVALID_ORIGIN.into()),
108                message: "Invalid origin".into(),
109            });
110        }
111        let nonce = query.get("popupNonce").map_or("", String::as_str);
112        let fail = |code: &str, description: String| {
113            completion(
114                origin,
115                &json!({"nonce":nonce,"error":{"code":code,"description":description}}),
116            )
117        };
118        for (key, code) in [
119            ("callbackURL", "invalid_callback_url"),
120            ("errorCallbackURL", "invalid_error_callback_url"),
121            ("newUserCallbackURL", "invalid_new_user_callback_url"),
122        ] {
123            if let Some(url) = query.get(key).filter(|url| !url.is_empty())
124                && !ctx.config.is_redirect_target_trusted(url)
125            {
126                return fail(code, format!("Untrusted URL: {url}"));
127            }
128        }
129        let Some(config) = ctx
130            .extensions
131            .get::<OAuthConfig>()
132            .filter(|config| config.providers.contains_key(provider))
133        else {
134            return fail(
135                "provider_not_found",
136                format!("Unknown provider: {provider}"),
137            );
138        };
139        let mut body = serde_json::Map::new();
140        _ = body.insert("provider".into(), json!(provider));
141        _ = body.insert(
142            "callbackURL".into(),
143            json!(
144                query
145                    .get("callbackURL")
146                    .filter(|v| !v.is_empty())
147                    .cloned()
148                    .unwrap_or_else(|| format!(
149                        "{}{}",
150                        ctx.config.base_url.trim_end_matches('/'),
151                        ctx.config.base_path
152                    ))
153            ),
154        );
155        for key in ["errorCallbackURL", "newUserCallbackURL"] {
156            if let Some(value) = query.get(key) {
157                _ = body.insert(key.into(), json!(value));
158            }
159        }
160        if query
161            .get("requestSignUp")
162            .is_some_and(|value| value == "true")
163        {
164            _ = body.insert("requestSignUp".into(), json!(true));
165        }
166        if let Some(scopes) = query.get("scopes").filter(|value| !value.is_empty()) {
167            _ = body.insert(
168                "scopes".into(),
169                json!(scopes.split(',').collect::<Vec<_>>()),
170            );
171        }
172        if let Some(data) = query
173            .get("additionalData")
174            .and_then(|value| serde_json::from_str::<Value>(value).ok())
175            .filter(Value::is_object)
176        {
177            _ = body.insert(
178                "additionalData".into(),
179                Value::Object(
180                    data.as_object()
181                        .into_iter()
182                        .flatten()
183                        .filter(|(key, _)| {
184                            !matches!(
185                                key.as_str(),
186                                "callbackURL"
187                                    | "codeVerifier"
188                                    | "errorURL"
189                                    | "newUserURL"
190                                    | "expiresAt"
191                                    | "oauthState"
192                                    | "link"
193                                    | "requestSignUp"
194                                    | "idTokenNonce"
195                                    | "serverContext"
196                            )
197                        })
198                        .map(|(key, value)| (key.clone(), value.clone()))
199                        .collect(),
200                ),
201            );
202        }
203        let body: super::oauth::OAuthSocialSignInRequest =
204            serde_json::from_value(Value::Object(body))?;
205        let Ok(mut response) =
206            super::oauth::start_social_flow_response(&body, &config, req.physical_request(), ctx)
207                .await
208                .and_then(|response| response.to_response())
209        else {
210            return fail(
211                "popup_sign_in_failed",
212                "Failed to start the OAuth flow.".into(),
213            );
214        };
215        if response.headers.get("location").is_none() {
216            return fail(
217                "popup_sign_in_failed",
218                "Failed to start the OAuth flow.".into(),
219            );
220        }
221        let marker = serde_json::to_string(&json!({"popupOrigin":origin,"popupNonce":nonce}))?;
222        response.headers.append(
223            "set-cookie",
224            create_cookie(
225                &related_cookie_name(&ctx.config, "oauth_popup"),
226                &sign_cookie_value(&marker, ctx.config.current_secret()),
227                600,
228                &ctx.config,
229            )?,
230        );
231        response.status = 302;
232        response.body.clear();
233        Ok(response)
234    }
235}
236
237#[async_trait]
238impl<S: AuthSchema> AuthPlugin<S> for OAuthPopupPlugin {
239    route_openapi_metadata!(S);
240
241    fn server_endpoints(&self) -> Vec<alibi_core::endpoint::EndpointDefinition> {
242        vec![crate::endpoint::definition(
243            "oauthPopupStart",
244            "oauthPopupStart",
245            Some("/oauth-popup/start"),
246            HttpMethod::Get,
247        )]
248    }
249    fn validate_endpoint(
250        &self,
251        call: &alibi_core::endpoint::EndpointCall,
252        _ctx: &AuthContext<S>,
253    ) -> AuthResult<alibi_core::endpoint::EndpointInput> {
254        use crate::authentication_helpers::JsonField;
255        Ok(alibi_core::endpoint::EndpointInput {
256            body: call.body().cloned(),
257            query: Some(crate::endpoint::validate_fields(
258                call.query(),
259                "query",
260                &[
261                    JsonField::string("provider", true),
262                    JsonField::string("popupOrigin", true),
263                    JsonField::string("popupNonce", false),
264                    JsonField::string("callbackURL", false),
265                    JsonField::string("errorCallbackURL", false),
266                    JsonField::string("newUserCallbackURL", false),
267                    JsonField::string("scopes", false),
268                    JsonField::string("requestSignUp", false),
269                    JsonField::string("additionalData", false),
270                ],
271            )?),
272        })
273    }
274    async fn on_endpoint(
275        &self,
276        call: &alibi_core::endpoint::EndpointCall,
277        ctx: &AuthContext<S>,
278    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
279        let output = self.start_core(call, ctx, &call.query_as()?).await?;
280        if output.status == 302 {
281            let mut response = alibi_core::endpoint::EndpointResponse::redirect(
282                output.headers.get("location").cloned().unwrap_or_default(),
283            );
284            response.merge_headers(output.headers);
285            return Ok(response);
286        }
287        Ok(alibi_core::endpoint::EndpointResponse::from_http_response(
288            output,
289        ))
290    }
291    fn endpoint_hooks(&self) -> Vec<&dyn alibi_core::endpoint::EndpointHook<S>> {
292        vec![self]
293    }
294    fn name(&self) -> &'static str {
295        "oauth-popup"
296    }
297    fn routes(&self) -> Vec<AuthRoute> {
298        vec![AuthRoute::get("/oauth-popup/start", "oauthPopupStart")]
299    }
300    async fn on_request(
301        &self,
302        req: &AuthRequest,
303        ctx: &AuthContext<S>,
304    ) -> AuthResult<Option<AuthResponse>> {
305        if *req.method() == HttpMethod::Get && req.path() == "/oauth-popup/start" {
306            return Ok(Some(self.start(req, ctx).await?));
307        }
308        Ok(None)
309    }
310    async fn after_request(
311        &self,
312        req: &AuthRequest,
313        ctx: &AuthContext<S>,
314        response: AuthResponse,
315    ) -> AuthResult<AuthResponse> {
316        Self::complete(req, req.path(), ctx, response)
317    }
318}
319
320impl OAuthPopupPlugin {
321    fn complete<S: AuthSchema>(
322        req: &impl alibi_core::session::SessionRequest,
323        path: &str,
324        ctx: &AuthContext<S>,
325        mut response: AuthResponse,
326    ) -> AuthResult<AuthResponse> {
327        if !(path.starts_with("/callback/") || path.starts_with("/oauth2/callback/")) {
328            return Ok(response);
329        }
330        let Some(redirect) = response.headers.get("location").cloned() else {
331            return Ok(response);
332        };
333        let name = related_cookie_name(&ctx.config, "oauth_popup");
334        let marker = req
335            .session_headers()
336            .get("cookie")
337            .and_then(|header| {
338                header.split(';').find_map(|part| {
339                    let (key, value) = part.trim().split_once('=')?;
340                    (key == name).then_some(value)
341                })
342            })
343            .and_then(|value| verify_cookie_value(value, ctx.config.current_secret()));
344        let Some(marker) = marker else {
345            return Ok(response);
346        };
347        response
348            .headers
349            .append("set-cookie", create_clear_cookie(&name, &ctx.config)?);
350        let Ok(marker) = serde_json::from_str::<Marker>(&marker) else {
351            return Ok(response);
352        };
353        let token_name = related_cookie_name(&ctx.config, "session_token");
354        let token = response.headers.get_all("set-cookie").find_map(|raw| {
355            cookie::Cookie::parse(raw.clone())
356                .ok()
357                .filter(|cookie| cookie.name() == token_name)
358                .map(|cookie| {
359                    urlencoding::decode(cookie.value())
360                        .map_or_else(|_| cookie.value().to_owned(), std::borrow::Cow::into_owned)
361                })
362        });
363        let message = if let Some(token) = token.filter(|value| !value.is_empty()) {
364            json!({"nonce":marker.popup_nonce,"token":token,"redirectTo":redirect})
365        } else {
366            let Ok(base) = url::Url::parse(&format!(
367                "{}{}",
368                ctx.config.base_url.trim_end_matches('/'),
369                ctx.config.base_path
370            )) else {
371                return Ok(response);
372            };
373            let Ok(url) = base.join(&redirect) else {
374                return Ok(response);
375            };
376            let Some(error) = url
377                .query_pairs()
378                .find(|(key, _)| key == "error")
379                .map(|(_, value)| value.into_owned())
380                .filter(|value| !value.is_empty())
381            else {
382                return Ok(response);
383            };
384            let mut error_data = serde_json::Map::new();
385            _ = error_data.insert("code".into(), json!(error));
386            if let Some(description) = url
387                .query_pairs()
388                .find(|(key, _)| key == "error_description")
389                .map(|(_, value)| value.into_owned())
390            {
391                _ = error_data.insert("description".into(), json!(description));
392            }
393            json!({"nonce":marker.popup_nonce,"error":error_data})
394        };
395        let html = completion(&marker.popup_origin, &message)?;
396        response.status = html.status;
397        response.body = html.body;
398        for (name, value) in html.headers.iter() {
399            _ = response.headers.insert(name.clone(), value.clone());
400        }
401        Ok(response)
402    }
403}
404
405#[async_trait]
406impl<S: AuthSchema> alibi_core::endpoint::EndpointHook<S> for OAuthPopupPlugin {
407    fn matches_after(
408        &self,
409        call: &alibi_core::endpoint::EndpointCall,
410        _: &AuthContext<S>,
411        _: &alibi_core::endpoint::EndpointResponse,
412    ) -> AuthResult<bool> {
413        Ok(call.path().is_some_and(|path| {
414            path.starts_with("/callback/") || path.starts_with("/oauth2/callback/")
415        }))
416    }
417    async fn after(
418        &self,
419        call: &alibi_core::endpoint::EndpointCall,
420        ctx: &AuthContext<S>,
421        mut response: alibi_core::endpoint::EndpointResponse,
422    ) -> AuthResult<alibi_core::endpoint::EndpointResponse> {
423        let wire = response.to_response()?;
424        let cookie_count = wire.headers.get_all("set-cookie").count();
425        let completed = Self::complete(call, call.path().unwrap_or_default(), ctx, wire)?;
426        if completed.status == 200
427            && completed
428                .headers
429                .get("content-type")
430                .is_some_and(|value| value.starts_with("text/html"))
431        {
432            return Ok(alibi_core::endpoint::EndpointResponse::from_http_response(
433                completed,
434            ));
435        }
436        // Invalid markers may expire their cookie without replacing a redirect.
437        for value in completed.headers.get_all("set-cookie").skip(cookie_count) {
438            response = response.with_header("set-cookie", value.clone());
439        }
440        Ok(response)
441    }
442}