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