Skip to main content

alibi_api/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        drop(body.insert("provider".into(), json!(provider)));
101        drop(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                drop(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            drop(body.insert("requestSignUp".into(), json!(true)));
126        }
127        if let Some(scopes) = req.query.get("scopes").filter(|value| !value.is_empty()) {
128            drop(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            drop(
140                body.insert(
141                    "additionalData".into(),
142                    Value::Object(
143                        data.as_object()
144                            .into_iter()
145                            .flatten()
146                            .filter(|(key, _)| {
147                                !matches!(
148                                    key.as_str(),
149                                    "callbackURL"
150                                        | "codeVerifier"
151                                        | "errorURL"
152                                        | "newUserURL"
153                                        | "expiresAt"
154                                        | "oauthState"
155                                        | "link"
156                                        | "requestSignUp"
157                                        | "idTokenNonce"
158                                        | "serverContext"
159                                )
160                            })
161                            .map(|(key, value)| (key.clone(), value.clone()))
162                            .collect(),
163                    ),
164                ),
165            );
166        }
167        let mut start_req = req.clone();
168        start_req.body = Some(serde_json::to_vec(&body)?);
169        let Ok(mut response) = handle_social_sign_in(&config, &start_req, ctx).await else {
170            return fail(
171                "popup_sign_in_failed",
172                "Failed to start the OAuth flow.".into(),
173            );
174        };
175        if response.headers.get("location").is_none() {
176            return fail(
177                "popup_sign_in_failed",
178                "Failed to start the OAuth flow.".into(),
179            );
180        }
181        let marker = serde_json::to_string(&json!({"popupOrigin":origin,"popupNonce":nonce}))?;
182        response.headers.append(
183            "set-cookie",
184            create_cookie(
185                &related_cookie_name(&ctx.config, "oauth_popup"),
186                &sign_cookie_value(&marker, ctx.config.current_secret()),
187                600,
188                &ctx.config,
189            )?,
190        );
191        response.status = 302;
192        response.body.clear();
193        Ok(response)
194    }
195}
196
197#[async_trait]
198impl<S: AuthSchema> AuthPlugin<S> for OAuthPopupPlugin {
199    fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
200        crate::metadata::plugin_metadata(
201            <Self as alibi_core::AuthPlugin<S>>::name(self),
202            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
203        )
204    }
205
206    fn openapi_metadata(
207        &self,
208        ctx: &alibi_core::AuthInitContext<S>,
209    ) -> alibi_core::PluginOpenApiMetadata {
210        crate::metadata::instance_plugin_metadata(
211            <Self as alibi_core::AuthPlugin<S>>::name(self),
212            &<Self as alibi_core::AuthPlugin<S>>::routes(self),
213            ctx,
214        )
215    }
216
217    fn name(&self) -> &'static str {
218        "oauth-popup"
219    }
220    fn routes(&self) -> Vec<AuthRoute> {
221        vec![AuthRoute::get("/oauth-popup/start", "oauthPopupStart")]
222    }
223    async fn on_request(
224        &self,
225        req: &AuthRequest,
226        ctx: &AuthContext<S>,
227    ) -> AuthResult<Option<AuthResponse>> {
228        if *req.method() == HttpMethod::Get && req.path() == "/oauth-popup/start" {
229            return Ok(Some(self.start(req, ctx).await?));
230        }
231        Ok(None)
232    }
233    async fn after_request(
234        &self,
235        req: &AuthRequest,
236        ctx: &AuthContext<S>,
237        mut response: AuthResponse,
238    ) -> AuthResult<AuthResponse> {
239        if !(req.path().starts_with("/callback/") || req.path().starts_with("/oauth2/callback/")) {
240            return Ok(response);
241        }
242        let Some(redirect) = response.headers.get("location").cloned() else {
243            return Ok(response);
244        };
245        let name = related_cookie_name(&ctx.config, "oauth_popup");
246        let marker = req
247            .headers
248            .get("cookie")
249            .and_then(|header| {
250                header.split(';').find_map(|part| {
251                    let (key, value) = part.trim().split_once('=')?;
252                    (key == name).then_some(value)
253                })
254            })
255            .and_then(|value| verify_cookie_value(value, ctx.config.current_secret()));
256        let Some(marker) = marker else {
257            return Ok(response);
258        };
259        response
260            .headers
261            .append("set-cookie", create_clear_cookie(&name, &ctx.config)?);
262        let Ok(marker) = serde_json::from_str::<Marker>(&marker) else {
263            return Ok(response);
264        };
265        let token_name = related_cookie_name(&ctx.config, "session_token");
266        let token = response.headers.get_all("set-cookie").find_map(|raw| {
267            cookie::Cookie::parse(raw.clone())
268                .ok()
269                .filter(|cookie| cookie.name() == token_name)
270                .map(|cookie| {
271                    urlencoding::decode(cookie.value())
272                        .map_or_else(|_| cookie.value().to_owned(), |value| value.into_owned())
273                })
274        });
275        let message = if let Some(token) = token.filter(|value| !value.is_empty()) {
276            json!({"nonce":marker.popup_nonce,"token":token,"redirectTo":redirect})
277        } else {
278            let Ok(base) = url::Url::parse(&format!(
279                "{}{}",
280                ctx.config.base_url.trim_end_matches('/'),
281                ctx.config.base_path
282            )) else {
283                return Ok(response);
284            };
285            let Ok(url) = base.join(&redirect) else {
286                return Ok(response);
287            };
288            let Some(error) = url
289                .query_pairs()
290                .find(|(key, _)| key == "error")
291                .map(|(_, value)| value.into_owned())
292                .filter(|value| !value.is_empty())
293            else {
294                return Ok(response);
295            };
296            let mut error_data = serde_json::Map::new();
297            drop(error_data.insert("code".into(), json!(error)));
298            if let Some(description) = url
299                .query_pairs()
300                .find(|(key, _)| key == "error_description")
301                .map(|(_, value)| value.into_owned())
302            {
303                drop(error_data.insert("description".into(), json!(description)));
304            }
305            json!({"nonce":marker.popup_nonce,"error":error_data})
306        };
307        let html = completion(&marker.popup_origin, message)?;
308        response.status = html.status;
309        response.body = html.body;
310        for (name, value) in html.headers.iter() {
311            drop(response.headers.insert(name.clone(), value.clone()));
312        }
313        Ok(response)
314    }
315}