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 _ = 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}