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