1use std::{future::Future, pin::Pin, sync::Arc};
4
5use async_trait::async_trait;
6use better_auth_core::{
7 AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, AuthSession,
8 AuthUser, CreateUser, CreateVerification, RequestMeta, middleware::EndpointRateLimit,
9};
10use chrono::{Duration, Utc};
11use rand::Rng;
12use rand::distributions::Alphanumeric;
13use serde::{Deserialize, Serialize};
14use url::Url;
15
16use super::{
17 helpers::{SessionIssueError, apply_default_role, issue_user_session},
18 one_time_token::TokenStorage,
19};
20
21mod callbacks;
22pub use callbacks::{MagicLinkCallbackFuture, MagicLinkCallbacks};
23
24#[derive(Debug, Clone, Serialize)]
26pub struct MagicLinkMessage {
27 pub email: String,
29 pub url: String,
31 pub token: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
36}
37
38#[async_trait]
40pub trait SendMagicLink: Send + Sync {
41 async fn send(&self, message: &MagicLinkMessage) -> AuthResult<()>;
43}
44
45pub type MagicLinkTokenGenerator =
47 Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = AuthResult<String>> + Send>> + Send + Sync>;
48
49#[derive(better_auth_core::PluginConfig)]
51#[plugin(name = "MagicLinkPlugin")]
52pub struct MagicLinkConfig {
53 #[config(default = Duration::minutes(5))]
55 pub expires_in: Duration,
56 #[config(default = false)]
58 pub disable_sign_up: bool,
59 #[config(default = TokenStorage::Plain)]
61 pub store_token: TokenStorage,
62 #[config(default = None)]
64 pub generate_token: Option<MagicLinkTokenGenerator>,
65 #[config(default = None, skip)]
67 pub send_magic_link: Option<Arc<dyn SendMagicLink>>,
68 #[config(default = 60.0)]
70 pub rate_limit_window: f64,
71 #[config(default = 5.0)]
73 pub rate_limit_max: f64,
74}
75
76pub struct MagicLinkPlugin {
78 config: MagicLinkConfig,
79}
80
81better_auth_core::impl_auth_plugin! {
82 MagicLinkPlugin, "magic-link";
83 routes {
84 post "/sign-in/magic-link" => handle_send, "signInWithMagicLink", body = send_body, require_headers = true;
85 get "/magic-link/verify" => handle_verify, "verifyMagicLink", query = crate::plugins::query_input::magic_link, require_headers = true;
86 }
87 extra {
88 fn rate_limits(&self) -> AuthResult<Vec<better_auth_core::middleware::PluginRateLimit>> {
89 let window = self.config.rate_limit_window;
90 let max = self.config.rate_limit_max;
91 let rule = EndpointRateLimit {
92 window: if window == 0.0 || window.is_nan() { 60.0 } else { window },
93 max_requests: if max == 0.0 || max.is_nan() { 5.0 } else { max },
94 };
95 Ok(["/sign-in/magic-link", "/magic-link/verify"].into_iter()
96 .map(|path| better_auth_core::middleware::PluginRateLimit::prefix(path, rule)).collect())
97 }
98 }
99}
100
101#[derive(Clone, Deserialize, Serialize)]
102#[serde(rename_all = "camelCase")]
103struct SendBody {
104 email: String,
105 #[serde(skip_serializing_if = "Option::is_none")]
106 name: Option<String>,
107 #[serde(rename = "callbackURL", skip_serializing_if = "Option::is_none")]
108 callback_url: Option<String>,
109 #[serde(rename = "newUserCallbackURL", skip_serializing_if = "Option::is_none")]
110 new_user_callback_url: Option<String>,
111 #[serde(rename = "errorCallbackURL", skip_serializing_if = "Option::is_none")]
112 error_callback_url: Option<String>,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 metadata: Option<serde_json::Map<String, serde_json::Value>>,
115}
116
117#[derive(Serialize, Deserialize)]
118struct Proof {
119 email: String,
120 #[serde(skip_serializing_if = "Option::is_none")]
121 name: Option<String>,
122}
123
124impl MagicLinkPlugin {
125 pub fn custom_send_magic_link(mut self, sender: Arc<dyn SendMagicLink>) -> Self {
127 self.config.send_magic_link = Some(sender);
128 self
129 }
130
131 async fn handle_send<S: AuthSchema>(
132 &self,
133 req: &AuthRequest,
134 ctx: &AuthContext<S>,
135 ) -> AuthResult<AuthResponse> {
136 let body: SendBody = match parse_send(req) {
137 Ok(body) => body,
138 Err(response) => return Ok(response),
139 };
140 let endpoint = super::endpoint_context::EndpointContext::new(
141 Some(req),
142 better_auth_core::FieldValue::from_json(serde_json::to_value(&body)?)?,
143 ctx,
144 );
145 let token = match &self.config.generate_token {
146 Some(generate) => generate(body.email.clone()).await?,
147 None => rand::thread_rng()
148 .sample_iter(Alphanumeric)
149 .filter(u8::is_ascii_alphabetic)
150 .take(32)
151 .map(char::from)
152 .collect(),
153 };
154 let stored = self.config.store_token.encode(&token).await?;
155 let lifetime = if self.config.expires_in.is_zero() {
156 Duration::minutes(5)
157 } else {
158 self.config.expires_in
159 };
160 let _ = ctx
161 .database
162 .create_verification(CreateVerification {
163 identifier: (stored).into(),
164 value: (serde_json::to_string(&Proof {
165 email: body.email.clone(),
166 name: body.name.clone(),
167 })?)
168 .into(),
169 expires_at: (Utc::now() + lifetime).into(),
170 ..Default::default()
171 })
172 .await?;
173 let mut url = Url::parse(ctx.base_url())
174 .map_err(|error| AuthError::config(format!("Invalid base URL: {error}")))?;
175 let base_path = if url.path() == "/" {
176 ctx.config.base_path.as_str()
177 } else {
178 url.path()
179 };
180 url.set_path(&format!(
181 "{}/magic-link/verify",
182 base_path.trim_end_matches('/')
183 ));
184 {
185 let mut query = url.query_pairs_mut();
186 let _ = query.append_pair("token", &token);
187 let _ = query.append_pair(
188 "callbackURL",
189 body.callback_url
190 .as_deref()
191 .filter(|value| !value.is_empty())
192 .unwrap_or("/"),
193 );
194 if let Some(value) = body.new_user_callback_url.filter(|value| !value.is_empty()) {
195 let _ = query.append_pair("newUserCallbackURL", &value);
196 }
197 if let Some(value) = body.error_callback_url.filter(|value| !value.is_empty()) {
198 let _ = query.append_pair("errorCallbackURL", &value);
199 }
200 }
201 let message = MagicLinkMessage {
202 email: body.email,
203 url: url.into(),
204 token,
205 metadata: body.metadata,
206 };
207 if let Some(callbacks) = ctx.extensions.get::<Arc<MagicLinkCallbacks<S>>>() {
208 (callbacks.sender)(&message, &endpoint).await?;
209 } else {
210 self.config
211 .send_magic_link
212 .as_ref()
213 .ok_or_else(|| AuthError::config("Magic link requires a sender"))?
214 .send(&message)
215 .await?;
216 }
217 Ok(AuthResponse::json(
218 200,
219 &serde_json::json!({ "status": true }),
220 )?)
221 }
222
223 async fn handle_verify<S: AuthSchema>(
224 &self,
225 req: &AuthRequest,
226 ctx: &AuthContext<S>,
227 ) -> AuthResult<AuthResponse> {
228 let Some(token) = req.query_string("token")? else {
229 return Ok(super::json_body::validation_error(
230 "[query.token] Invalid input: expected string, received undefined",
231 ));
232 };
233 let base = Url::parse(ctx.base_url())
234 .map_err(|error| AuthError::config(format!("Invalid base URL: {error}")))?;
235 let callback = callback_url(req, ctx, &base, "callbackURL", "/")?;
236 let error_callback = callback_url(req, ctx, &base, "errorCallbackURL", callback.as_str())?;
237 let new_user_callback =
238 callback_url(req, ctx, &base, "newUserCallbackURL", callback.as_str())?;
239 let stored = self.config.store_token.encode(token).await?;
240 let Some(value) = ctx
241 .database
242 .consume_verification_by_identifier(&stored)
243 .await?
244 else {
245 return Ok(error_redirect(error_callback, "INVALID_TOKEN"));
246 };
247 let proof: Proof = serde_json::from_str(&value.value.display_string()?)?;
248 let existing = ctx.database.get_user_by_email(&proof.email).await?;
249 let is_new_user = existing.is_none();
250 let user = match existing {
251 Some(user) => user,
252 None if self.config.disable_sign_up => {
253 return Ok(error_redirect(error_callback, "new_user_signup_disabled"));
254 }
255 None => {
256 let mut user = CreateUser::new()
257 .with_email(proof.email)
258 .with_name(proof.name.unwrap_or_default())
259 .with_email_verified(true);
260
261 let endpoint = super::endpoint_context::EndpointContext::new(
262 Some(req),
263 better_auth_core::FieldValue::Null,
264 ctx,
265 );
266 if let Err(rejection) = super::user_admission::validate_create(
267 &user,
268 super::user_admission::UserValidationSource::new(
269 "magic-link",
270 super::user_admission::UserValidationAction::CreateUser,
271 ),
272 &endpoint,
273 )
274 .await
275 {
276 let message = rejection.message().to_owned();
277 let mut target = error_callback;
278 let pairs: Vec<_> = target
279 .query_pairs()
280 .filter(|(key, _)| key != "error" && key != "error_description")
281 .map(|(key, value)| (key.into_owned(), value.into_owned()))
282 .collect();
283 let _ = target
284 .query_pairs_mut()
285 .clear()
286 .extend_pairs(pairs)
287 .append_pair("error", &rejection.error)
288 .append_pair("error_description", &message);
289 return Ok(redirect(target));
290 }
291 apply_default_role(ctx, &mut user);
292 ctx.database.create_user(user).await?
293 }
294 };
295 let user = if user.email_verified() {
296 user
297 } else {
298 let Some(user) = ctx
299 .database
300 .verify_user_and_revoke_unproven_access(user.id().typed()?)
301 .await?
302 else {
303 return Ok(error_redirect(error_callback, "user_not_found"));
304 };
305 user
306 };
307 let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
308 let issued = issue_user_session(ctx, user.id().typed()?, meta.ip_address, meta.user_agent)
309 .await
310 .map_err(SessionIssueError::into_auth_error)?;
311 let response = if req.query_string("callbackURL")?.is_none_or(str::is_empty) {
312 AuthResponse::json(
313 200,
314 &serde_json::json!({ "token": issued.session.token(), "session": ctx.session_view(&issued.session).await?, "user": ctx.user_view(&issued.user).await? }),
315 )?
316 } else {
317 redirect(if is_new_user {
318 new_user_callback
319 } else {
320 callback
321 })
322 };
323 let manager = ctx.session_manager();
324 manager
325 .set_session_cookie(
326 req,
327 manager.internal_data(&issued.user, &issued.session).await?,
328 None,
329 )
330 .await?;
331 Ok(response)
332 }
333}
334
335fn callback_url<S: AuthSchema>(
336 req: &AuthRequest,
337 ctx: &AuthContext<S>,
338 base: &Url,
339 key: &str,
340 default: &str,
341) -> AuthResult<Url> {
342 let value = req
343 .query_string(key)?
344 .filter(|value| !value.is_empty())
345 .unwrap_or(default);
346 let decoded = urlencoding::decode(value)
347 .map_err(|error| AuthError::bad_request(format!("Invalid callback URL: {error}")))?;
348 if !ctx.config.advanced.disable_origin_check && !ctx.is_redirect_target_trusted(&decoded) {
349 return Err(AuthError::forbidden("Invalid callbackURL"));
350 }
351 base.join(&decoded)
352 .map_err(|error| AuthError::bad_request(format!("Invalid callback URL: {error}")))
353}
354
355fn parse_send(req: &AuthRequest) -> Result<SendBody, AuthResponse> {
356 use super::json_body;
357 if let Some(body) = req.validated_body::<SendBody>() {
358 return Ok(body.clone());
359 }
360 let value = req.input_body().map_err(|error| error.to_auth_response())?;
361 let Some(serde_json::Value::Object(body)) = value.as_ref() else {
362 return Err(json_body::validation_error(&json_body::invalid_type(
363 "body",
364 "object",
365 value.as_ref(),
366 )));
367 };
368 let mut errors = Vec::new();
369 for field in [
370 "email",
371 "name",
372 "callbackURL",
373 "newUserCallbackURL",
374 "errorCallbackURL",
375 "metadata",
376 ] {
377 let value = body.get(field);
378 if field != "email" && value.is_none() {
379 continue;
380 }
381 let expected = if field == "metadata" {
382 "record"
383 } else {
384 "string"
385 };
386 if !value.is_some_and(|value| {
387 if field == "metadata" {
388 value.is_object()
389 } else {
390 value.is_string()
391 }
392 }) {
393 errors.push(json_body::invalid_type(
394 &format!("body.{field}"),
395 expected,
396 value,
397 ));
398 } else if field == "email"
399 && !json_body::valid_email(
400 value
401 .and_then(serde_json::Value::as_str)
402 .unwrap_or_default(),
403 )
404 .map_err(|error| error.to_auth_response())?
405 {
406 errors.push("[body.email] Invalid email address".into());
407 }
408 }
409 if !errors.is_empty() {
410 return Err(json_body::validation_error(&errors.join("; ")));
411 }
412 serde_json::from_value(value.unwrap_or_default())
413 .map_err(|error| json_body::validation_error(&error.to_string()))
414}
415
416fn redirect(url: Url) -> AuthResponse {
417 AuthResponse::new(302)
418 .with_header("Location", url.to_string())
419 .with_header("content-type", "application/json")
420}
421
422fn error_redirect(mut url: Url, error: &str) -> AuthResponse {
423 let pairs: Vec<_> = url
424 .query_pairs()
425 .filter(|(key, _)| key != "error")
426 .map(|(key, value)| (key.into_owned(), value.into_owned()))
427 .collect();
428 let _ = url
429 .query_pairs_mut()
430 .clear()
431 .extend_pairs(pairs)
432 .append_pair("error", error);
433 redirect(url)
434}
435
436fn send_body(req: &AuthRequest) -> AuthResult<better_auth_core::endpoint_input::ValidatedBody> {
437 let body = parse_send(req).map_err(AuthError::from)?;
438 Ok(better_auth_core::endpoint_input::ValidatedBody::new(
439 Some(serde_json::to_value(&body)?),
440 body,
441 ))
442}