1use 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#[derive(Clone, Debug, Default)]
22pub struct OAuthPopupPlugin;
23
24#[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 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 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}