1use 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#[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}