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, handlers::handle_social_sign_in};
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;
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#[derive(Deserialize)]
24#[serde(rename_all = "camelCase")]
25struct Marker {
26    popup_origin: String,
27    #[serde(default)]
28    popup_nonce: String,
29}
30
31fn completion(origin: &str, message: &Value) -> AuthResult<AuthResponse> {
32    let mut payload = json!({"type":"better-auth:oauth-popup", "targetOrigin":origin});
33    if let (Some(payload), Some(message)) = (payload.as_object_mut(), message.as_object()) {
34        payload.extend(message.clone());
35    }
36    let data = serde_json::to_string(&payload)?
37        .replace('<', "\\u003c")
38        .replace('\u{2028}', "\\u2028")
39        .replace('\u{2029}', "\\u2029");
40    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>"))
41        .with_header("content-security-policy", CSP)
42        .with_header("cache-control", "no-store").with_header("pragma", "no-cache"))
43}
44
45impl OAuthPopupPlugin {
46    #[must_use]
47    pub const fn new() -> Self {
48        Self
49    }
50
51    async fn start<S: AuthSchema>(
52        &self,
53        req: &AuthRequest,
54        ctx: &AuthContext<S>,
55    ) -> AuthResult<AuthResponse> {
56        let origin = req
57            .query
58            .get("popupOrigin")
59            .ok_or_else(|| AuthError::bad_request("popupOrigin is required"))?;
60        let provider = req
61            .query
62            .get("provider")
63            .ok_or_else(|| AuthError::bad_request("provider is required"))?;
64        if !ctx.config.is_origin_trusted(origin) {
65            return Err(AuthError::Api {
66                status: 403,
67                code: Some("INVALID_ORIGIN".into()),
68                message: "Invalid origin".into(),
69            });
70        }
71        let nonce = req.query.get("popupNonce").map_or("", String::as_str);
72        let fail = |code: &str, description: String| {
73            completion(
74                origin,
75                &json!({"nonce":nonce,"error":{"code":code,"description":description}}),
76            )
77        };
78        for (key, code) in [
79            ("callbackURL", "invalid_callback_url"),
80            ("errorCallbackURL", "invalid_error_callback_url"),
81            ("newUserCallbackURL", "invalid_new_user_callback_url"),
82        ] {
83            if let Some(url) = req.query.get(key).filter(|url| !url.is_empty())
84                && !ctx.config.is_redirect_target_trusted(url)
85            {
86                return fail(code, format!("Untrusted URL: {url}"));
87            }
88        }
89        let Some(config) = ctx
90            .extensions
91            .get::<OAuthConfig>()
92            .filter(|config| config.providers.contains_key(provider))
93        else {
94            return fail(
95                "provider_not_found",
96                format!("Unknown provider: {provider}"),
97            );
98        };
99        let mut body = serde_json::Map::new();
100        _ = body.insert("provider".into(), json!(provider));
101        _ = body.insert(
102            "callbackURL".into(),
103            json!(
104                req.query
105                    .get("callbackURL")
106                    .filter(|v| !v.is_empty())
107                    .cloned()
108                    .unwrap_or_else(|| format!(
109                        "{}{}",
110                        ctx.config.base_url.trim_end_matches('/'),
111                        ctx.config.base_path
112                    ))
113            ),
114        );
115        for key in ["errorCallbackURL", "newUserCallbackURL"] {
116            if let Some(value) = req.query.get(key) {
117                _ = body.insert(key.into(), json!(value));
118            }
119        }
120        if req
121            .query
122            .get("requestSignUp")
123            .is_some_and(|value| value == "true")
124        {
125            _ = body.insert("requestSignUp".into(), json!(true));
126        }
127        if let Some(scopes) = req.query.get("scopes").filter(|value| !value.is_empty()) {
128            _ = body.insert(
129                "scopes".into(),
130                json!(scopes.split(',').collect::<Vec<_>>()),
131            );
132        }
133        if let Some(data) = req
134            .query
135            .get("additionalData")
136            .and_then(|value| serde_json::from_str::<Value>(value).ok())
137            .filter(Value::is_object)
138        {
139            _ = body.insert(
140                "additionalData".into(),
141                Value::Object(
142                    data.as_object()
143                        .into_iter()
144                        .flatten()
145                        .filter(|(key, _)| {
146                            !matches!(
147                                key.as_str(),
148                                "callbackURL"
149                                    | "codeVerifier"
150                                    | "errorURL"
151                                    | "newUserURL"
152                                    | "expiresAt"
153                                    | "oauthState"
154                                    | "link"
155                                    | "requestSignUp"
156                                    | "idTokenNonce"
157                                    | "serverContext"
158                            )
159                        })
160                        .map(|(key, value)| (key.clone(), value.clone()))
161                        .collect(),
162                ),
163            );
164        }
165        let mut start_req = req.clone();
166        start_req.body = Some(serde_json::to_vec(&body)?);
167        let Ok(mut response) = handle_social_sign_in(&config, &start_req, ctx).await else {
168            return fail(
169                "popup_sign_in_failed",
170                "Failed to start the OAuth flow.".into(),
171            );
172        };
173        if response.headers.get("location").is_none() {
174            return fail(
175                "popup_sign_in_failed",
176                "Failed to start the OAuth flow.".into(),
177            );
178        }
179        let marker = serde_json::to_string(&json!({"popupOrigin":origin,"popupNonce":nonce}))?;
180        response.headers.append(
181            "set-cookie",
182            create_cookie(
183                &related_cookie_name(&ctx.config, "oauth_popup"),
184                &sign_cookie_value(&marker, ctx.config.current_secret()),
185                600,
186                &ctx.config,
187            )?,
188        );
189        response.status = 302;
190        response.body.clear();
191        Ok(response)
192    }
193}
194
195#[async_trait]
196impl<S: AuthSchema> AuthPlugin<S> for OAuthPopupPlugin {
197    route_openapi_metadata!(S);
198
199    fn name(&self) -> &'static str {
200        "oauth-popup"
201    }
202    fn routes(&self) -> Vec<AuthRoute> {
203        vec![AuthRoute::get("/oauth-popup/start", "oauthPopupStart")]
204    }
205    async fn on_request(
206        &self,
207        req: &AuthRequest,
208        ctx: &AuthContext<S>,
209    ) -> AuthResult<Option<AuthResponse>> {
210        if *req.method() == HttpMethod::Get && req.path() == "/oauth-popup/start" {
211            return Ok(Some(self.start(req, ctx).await?));
212        }
213        Ok(None)
214    }
215    async fn after_request(
216        &self,
217        req: &AuthRequest,
218        ctx: &AuthContext<S>,
219        mut response: AuthResponse,
220    ) -> AuthResult<AuthResponse> {
221        if !(req.path().starts_with("/callback/") || req.path().starts_with("/oauth2/callback/")) {
222            return Ok(response);
223        }
224        let Some(redirect) = response.headers.get("location").cloned() else {
225            return Ok(response);
226        };
227        let name = related_cookie_name(&ctx.config, "oauth_popup");
228        let marker = req
229            .headers
230            .get("cookie")
231            .and_then(|header| {
232                header.split(';').find_map(|part| {
233                    let (key, value) = part.trim().split_once('=')?;
234                    (key == name).then_some(value)
235                })
236            })
237            .and_then(|value| verify_cookie_value(value, ctx.config.current_secret()));
238        let Some(marker) = marker else {
239            return Ok(response);
240        };
241        response
242            .headers
243            .append("set-cookie", create_clear_cookie(&name, &ctx.config)?);
244        let Ok(marker) = serde_json::from_str::<Marker>(&marker) else {
245            return Ok(response);
246        };
247        let token_name = related_cookie_name(&ctx.config, "session_token");
248        let token = response.headers.get_all("set-cookie").find_map(|raw| {
249            cookie::Cookie::parse(raw.clone())
250                .ok()
251                .filter(|cookie| cookie.name() == token_name)
252                .map(|cookie| {
253                    urlencoding::decode(cookie.value())
254                        .map_or_else(|_| cookie.value().to_owned(), std::borrow::Cow::into_owned)
255                })
256        });
257        let message = if let Some(token) = token.filter(|value| !value.is_empty()) {
258            json!({"nonce":marker.popup_nonce,"token":token,"redirectTo":redirect})
259        } else {
260            let Ok(base) = url::Url::parse(&format!(
261                "{}{}",
262                ctx.config.base_url.trim_end_matches('/'),
263                ctx.config.base_path
264            )) else {
265                return Ok(response);
266            };
267            let Ok(url) = base.join(&redirect) else {
268                return Ok(response);
269            };
270            let Some(error) = url
271                .query_pairs()
272                .find(|(key, _)| key == "error")
273                .map(|(_, value)| value.into_owned())
274                .filter(|value| !value.is_empty())
275            else {
276                return Ok(response);
277            };
278            let mut error_data = serde_json::Map::new();
279            _ = error_data.insert("code".into(), json!(error));
280            if let Some(description) = url
281                .query_pairs()
282                .find(|(key, _)| key == "error_description")
283                .map(|(_, value)| value.into_owned())
284            {
285                _ = error_data.insert("description".into(), json!(description));
286            }
287            json!({"nonce":marker.popup_nonce,"error":error_data})
288        };
289        let html = completion(&marker.popup_origin, &message)?;
290        response.status = html.status;
291        response.body = html.body;
292        for (name, value) in html.headers.iter() {
293            _ = response.headers.insert(name.clone(), value.clone());
294        }
295        Ok(response)
296    }
297}