Skip to main content

better_auth_api/plugins/
magic_link.rs

1//! Email proof using an atomically consumed sign-in link.
2
3use 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/// Payload delivered by the configured magic-link sender.
25#[derive(Debug, Clone, Serialize)]
26pub struct MagicLinkMessage {
27    /// Destination email address.
28    pub email: String,
29    /// Verification URL with callback parameters.
30    pub url: String,
31    /// Original, unstored verification token.
32    pub token: String,
33    /// Application data supplied by the sign-in request.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub metadata: Option<serde_json::Map<String, serde_json::Value>>,
36}
37
38/// Deliver magic links through the application's email service.
39#[async_trait]
40pub trait SendMagicLink: Send + Sync {
41    /// Send one link. Delivery failures propagate to the endpoint.
42    async fn send(&self, message: &MagicLinkMessage) -> AuthResult<()>;
43}
44
45/// Generate a token for the supplied email address.
46pub type MagicLinkTokenGenerator =
47    Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = AuthResult<String>> + Send>> + Send + Sync>;
48
49/// Magic-link options matching the upstream plugin.
50#[derive(better_auth_core::PluginConfig)]
51#[plugin(name = "MagicLinkPlugin")]
52pub struct MagicLinkConfig {
53    /// Verification lifetime. Zero uses the upstream five-minute default.
54    #[config(default = Duration::minutes(5))]
55    pub expires_in: Duration,
56    /// Deny verification for email addresses without an existing user.
57    #[config(default = false)]
58    pub disable_sign_up: bool,
59    /// Representation stored in the verification table.
60    #[config(default = TokenStorage::Plain)]
61    pub store_token: TokenStorage,
62    /// Optional custom token generation.
63    #[config(default = None)]
64    pub generate_token: Option<MagicLinkTokenGenerator>,
65    /// Required application email sender.
66    #[config(default = None, skip)]
67    pub send_magic_link: Option<Arc<dyn SendMagicLink>>,
68    /// Plugin endpoint limit window in seconds.
69    #[config(default = 60.0)]
70    pub rate_limit_window: f64,
71    /// Maximum requests in the limit window.
72    #[config(default = 5.0)]
73    pub rate_limit_max: f64,
74}
75
76/// Sign in or register after proving control of an email address.
77pub 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    /// Configure the application email sender.
126    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}