Skip to main content

better_auth_api/plugins/anonymous/
mod.rs

1use std::{future::Future, pin::Pin, sync::Arc};
2
3use better_auth_core::wire::{SessionView, UserView};
4use better_auth_core::{
5    AuthContext, AuthError, AuthRequest, AuthResponse, AuthResult, AuthSchema, AuthSession,
6    AuthUser, CreateUser, RequestMeta,
7};
8use rand::distributions::{Alphanumeric, DistString};
9use validator::ValidateEmail;
10
11use crate::plugins::helpers::{SessionIssueError, issue_user_session};
12
13mod callbacks;
14pub use callbacks::{AnonymousCallbackFuture, AnonymousCallbacks};
15
16type FutureResult<T> = Pin<Box<dyn Future<Output = AuthResult<T>> + Send>>;
17type Generator = dyn Fn() -> FutureResult<String> + Send + Sync;
18type NameGenerator = dyn Fn(AuthRequest) -> FutureResult<String> + Send + Sync;
19type LinkCallback = dyn Fn(AnonymousLink) -> FutureResult<()> + Send + Sync;
20
21/// The authenticated identities involved when an anonymous session is upgraded.
22#[derive(Debug, Clone)]
23pub struct AnonymousLink {
24    /// Anonymous identity that existed before authentication.
25    pub anonymous_user: UserView,
26    /// Active session for the anonymous identity.
27    pub anonymous_session: SessionView,
28    /// User value supplied by the completed endpoint.
29    pub new_user: better_auth_core::FieldValue,
30    /// Session issued by the completed endpoint.
31    pub new_session: SessionView,
32    /// Request that completed authentication.
33    pub request: AuthRequest,
34}
35
36/// Anonymous sign-in with persisted identities and post-sign-in account linking.
37#[derive(Clone, Default)]
38pub struct AnonymousPlugin {
39    email_domain_name: Option<String>,
40    generate_random_email: Option<Arc<Generator>>,
41    generate_name: Option<Arc<NameGenerator>>,
42    on_link_account: Option<Arc<LinkCallback>>,
43    disable_delete_anonymous_user: bool,
44}
45
46impl AnonymousPlugin {
47    /// Use upstream defaults for anonymous sign-in and account cleanup.
48    pub fn new() -> Self {
49        Self::default()
50    }
51    /// Use this domain for generated placeholder emails.
52    pub fn email_domain_name(mut self, domain: impl Into<String>) -> Self {
53        self.email_domain_name = Some(domain.into());
54        self
55    }
56    /// Disable explicit deletion and post-link anonymous identity cleanup.
57    pub fn disable_delete_anonymous_user(mut self, disable: bool) -> Self {
58        self.disable_delete_anonymous_user = disable;
59        self
60    }
61    /// Supply an email generator; an empty result uses the default generator.
62    pub fn generate_random_email<F, Fut>(mut self, callback: F) -> Self
63    where
64        F: Fn() -> Fut + Send + Sync + 'static,
65        Fut: Future<Output = AuthResult<String>> + Send + 'static,
66    {
67        self.generate_random_email = Some(Arc::new(move || Box::pin(callback())));
68        self
69    }
70    /// Generate a name from the sign-in request; an empty result uses `Anonymous`.
71    pub fn generate_name<F, Fut>(mut self, callback: F) -> Self
72    where
73        F: Fn(AuthRequest) -> Fut + Send + Sync + 'static,
74        Fut: Future<Output = AuthResult<String>> + Send + 'static,
75    {
76        self.generate_name = Some(Arc::new(move |request| Box::pin(callback(request))));
77        self
78    }
79    /// Transfer application data before the anonymous identity is deleted.
80    pub fn on_link_account<F, Fut>(mut self, callback: F) -> Self
81    where
82        F: Fn(AnonymousLink) -> Fut + Send + Sync + 'static,
83        Fut: Future<Output = AuthResult<()>> + Send + 'static,
84    {
85        self.on_link_account = Some(Arc::new(move |link| Box::pin(callback(link))));
86        self
87    }
88    async fn sign_in<S: AuthSchema>(
89        &self,
90        req: &AuthRequest,
91        ctx: &AuthContext<S>,
92    ) -> AuthResult<AuthResponse> {
93        let mut session_request = req.clone();
94        let mut query = session_request
95            .query
96            .take()
97            .and_then(|query| query.as_object().cloned())
98            .unwrap_or_default();
99        let _ = query.insert("disableRefresh".into(), serde_json::Value::Bool(true));
100        session_request.query = Some(query.into());
101        let previous = ctx
102            .session_manager()
103            .resolve(
104                &session_request,
105                better_auth_core::session::SessionRead::Cached,
106            )
107            .await?
108            .data;
109        if previous
110            .as_ref()
111            .is_some_and(|data| data.user.is_anonymous == Some(true))
112        {
113            return Err(error(
114                400,
115                "ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY",
116                "Anonymous users cannot sign in again anonymously",
117            ));
118        }
119        let custom_email = match &self.generate_random_email {
120            Some(generate) => generate().await?,
121            None => String::new(),
122        };
123        let email = if custom_email.is_empty() {
124            let id = Alphanumeric.sample_string(&mut rand::thread_rng(), 32);
125            match &self.email_domain_name {
126                Some(domain) => format!("temp-{id}@{domain}"),
127                None => format!("{id}@anonymous.placeholder.invalid"),
128            }
129        } else {
130            if !custom_email.validate_email() {
131                return Err(error(
132                    400,
133                    "INVALID_EMAIL_FORMAT",
134                    "Email was not generated in a valid format",
135                ));
136            }
137            custom_email
138        };
139        let body = req
140            .body
141            .as_deref()
142            .filter(|body| !body.is_empty())
143            .map(super::json_body::decode)
144            .transpose()
145            .map_err(AuthError::from)?
146            .unwrap_or(serde_json::Value::Null);
147        let mut endpoint = super::endpoint_context::EndpointContext::new(
148            Some(req),
149            better_auth_core::FieldValue::from_json(body)?,
150            ctx,
151        );
152        endpoint.session = previous.map(|data| (data.user, data.session));
153        let name = if let Some(generate) = ctx
154            .extensions
155            .get::<Arc<AnonymousCallbacks<S>>>()
156            .and_then(|callbacks| callbacks.name.as_ref())
157        {
158            generate(&endpoint).await?
159        } else if let Some(generate) = &self.generate_name {
160            generate(req.clone()).await?
161        } else {
162            String::new()
163        };
164        let mut create = CreateUser::new()
165            .with_email(email)
166            .with_name(if name.is_empty() {
167                "Anonymous".into()
168            } else {
169                name
170            });
171        create.is_anonymous = Some(true);
172        create.email_verified = Some(false);
173
174        let user = super::user_admission::create_user(create, "anonymous", &endpoint).await?;
175        let meta = RequestMeta::from_request_with_config(req, &ctx.config.advanced.ip_address);
176        let issued = issue_user_session(ctx, user.id().typed()?, meta.ip_address, meta.user_agent)
177            .await
178            .map_err(SessionIssueError::into_auth_error)?;
179        let manager = ctx.session_manager();
180        manager
181            .set_session_cookie(
182                req,
183                manager.internal_data(&issued.user, &issued.session).await?,
184                None,
185            )
186            .await?;
187        Ok(AuthResponse::json(
188            200,
189            &serde_json::json!({"token": issued.session.token(),"user":ctx.user_view(&issued.user).await?}),
190        )?)
191    }
192    async fn delete(
193        &self,
194        req: &AuthRequest,
195        ctx: &AuthContext<impl AuthSchema>,
196    ) -> AuthResult<AuthResponse> {
197        let (user, _) = ctx
198            .require_authoritative_session(req)
199            .await
200            .map_err(|cause| {
201                if matches!(cause, AuthError::Unauthenticated) {
202                    error(401, "UNAUTHORIZED", "Unauthorized")
203                } else {
204                    cause
205                }
206            })?;
207        if self.disable_delete_anonymous_user {
208            return Err(error(
209                400,
210                "DELETE_ANONYMOUS_USER_DISABLED",
211                "Deleting anonymous users is disabled",
212            ));
213        }
214        if user.is_anonymous != Some(true) {
215            return Err(error(403, "USER_IS_NOT_ANONYMOUS", "User is not anonymous"));
216        }
217        ctx.database
218            .delete_user_sessions(user.id.typed()?)
219            .await
220            .map_err(|cause| {
221                better_auth_core::observability::logger::current().error(
222                    "Failed to delete anonymous user sessions",
223                    &[better_auth_core::observability::LogArgument::Error(&cause)],
224                );
225                error(
226                    500,
227                    "FAILED_TO_DELETE_ANONYMOUS_USER_SESSIONS",
228                    "Failed to delete anonymous user sessions",
229                )
230            })?;
231        ctx.database
232            .delete_user(user.id.typed()?)
233            .await
234            .map_err(|cause| {
235                better_auth_core::observability::logger::current().error(
236                    "Failed to delete anonymous user",
237                    &[better_auth_core::observability::LogArgument::Error(&cause)],
238                );
239                error(
240                    500,
241                    "FAILED_TO_DELETE_ANONYMOUS_USER",
242                    "Failed to delete anonymous user",
243                )
244            })?;
245        ctx.session_manager().clear_cookies(req)?;
246        Ok(AuthResponse::json(
247            200,
248            &serde_json::json!({"success":true}),
249        )?)
250    }
251    async fn link<S: AuthSchema>(
252        &self,
253        req: &AuthRequest,
254        response: &AuthResponse,
255        ctx: &AuthContext<S>,
256    ) -> AuthResult<()> {
257        if ![
258            "/sign-in",
259            "/sign-up",
260            "/callback",
261            "/magic-link/verify",
262            "/email-otp/verify-email",
263            "/one-tap/callback",
264            "/passkey/verify-authentication",
265            "/phone-number/verify",
266            "/verify-email",
267        ]
268        .iter()
269        .any(|prefix| req.path().starts_with(prefix))
270        {
271            return Ok(());
272        }
273        let Some(cookie) = response
274            .headers
275            .get_all("set-cookie")
276            .filter_map(|value| cookie::Cookie::parse(value.as_str()).ok())
277            .find(|cookie| {
278                cookie.name()
279                    == ctx
280                        .config
281                        .auth_cookie("session_token", Default::default())
282                        .name
283                    && !cookie.value().is_empty()
284            })
285        else {
286            return Ok(());
287        };
288        if cookie.value().split('.').next().is_none_or(str::is_empty) {
289            return Ok(());
290        }
291        let mut session_request = req.clone();
292        let mut query = session_request
293            .query
294            .take()
295            .and_then(|query| query.as_object().cloned())
296            .unwrap_or_default();
297        let _ = query.insert("disableRefresh".into(), serde_json::Value::Bool(true));
298        session_request.query = Some(query.into());
299        let previous = ctx
300            .session_manager()
301            .resolve(
302                &session_request,
303                better_auth_core::session::SessionRead::Cached,
304            )
305            .await?
306            .data;
307        let previous = match previous.filter(|session| session.user.is_anonymous == Some(true)) {
308            Some(previous) => Some(previous),
309            None => {
310                if let Some(user_id) = req
311                    .server_context("anonymousUserId")?
312                    .and_then(|value| value.as_str().map(str::to_owned))
313                {
314                    if let Some(user) = ctx
315                        .database
316                        .get_user_by_id(&user_id)
317                        .await?
318                        .filter(|user| user.is_anonymous() == Some(true))
319                    {
320                        let session = ctx
321                            .database
322                            .get_user_session_snapshots(&user_id)
323                            .await?
324                            .into_iter()
325                            .find(|(session, _)| {
326                                session.expires_at().milliseconds()
327                                    > chrono::Utc::now().timestamp_millis() as f64
328                            });
329                        if let Some((session, snapshot)) = session {
330                            Some(better_auth_core::session::SessionData {
331                                user: ctx.internal_user_view(&user).await?,
332                                session: match snapshot {
333                                    Some(session) => session,
334                                    None => {
335                                        ctx.session_manager()
336                                            .internal_session_view(&session)
337                                            .await?
338                                    }
339                                },
340                            })
341                        } else {
342                            None
343                        }
344                    } else {
345                        None
346                    }
347                } else {
348                    None
349                }
350            }
351        };
352        let Some(previous) = previous else {
353            return Ok(());
354        };
355        if previous.user.is_anonymous != Some(true) {
356            return Ok(());
357        }
358        let Some(new_session) = req.new_session()? else {
359            if req.path() == "/sign-in/anonymous" {
360                return Err(error(
361                    400,
362                    "ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY",
363                    "Anonymous users cannot sign in again anonymously",
364                ));
365            }
366            return Ok(());
367        };
368        let link = AnonymousLink {
369            anonymous_user: previous.user.clone(),
370            anonymous_session: previous.session.clone(),
371            new_user: new_session.user.clone(),
372            new_session: new_session.session.clone(),
373            request: req.clone(),
374        };
375        if let Some(callback) = ctx
376            .extensions
377            .get::<Arc<AnonymousCallbacks<S>>>()
378            .and_then(|callbacks| callbacks.link.as_ref())
379        {
380            let body = req
381                .body
382                .as_deref()
383                .filter(|body| !body.is_empty())
384                .map(super::json_body::decode)
385                .transpose()
386                .map_err(AuthError::from)?
387                .unwrap_or(serde_json::Value::Null);
388            let mut endpoint = super::endpoint_context::EndpointContext::new(
389                Some(req),
390                better_auth_core::FieldValue::from_json(body)?,
391                ctx,
392            );
393            endpoint.session = Some((previous.user.clone(), previous.session));
394            endpoint.response = Some(response);
395            callback(&link, &endpoint).await?;
396        } else if let Some(callback) = &self.on_link_account {
397            callback(link).await?;
398        }
399        if !self.disable_delete_anonymous_user
400            && !previous
401                .user
402                .id
403                .field_value()
404                .strict_equals(new_session.user_field("id"))
405            && new_session.user_field("isAnonymous").as_bool() != Some(true)
406        {
407            // Upstream keeps a successful sign-in when post-link cleanup fails.
408            if let Err(cause) = ctx.database.delete_user(previous.user.id.typed()?).await {
409                better_auth_core::observability::logger::current().error(
410                    "Failed to clean up anonymous user during post-link cleanup",
411                    &[better_auth_core::observability::LogArgument::Error(&cause)],
412                );
413            }
414        }
415        Ok(())
416    }
417}
418
419fn error(status: u16, code: &'static str, message: &'static str) -> AuthError {
420    AuthError::Upstream {
421        status,
422        code,
423        message,
424    }
425}
426
427better_auth_core::impl_auth_plugin!(AnonymousPlugin, "anonymous";
428    routes {
429        post "/sign-in/anonymous" => sign_in, "signInAnonymous";
430        post "/delete-anonymous-user" => delete, "deleteAnonymousUser";
431    }
432    extra {
433        async fn on_init(
434            &self,
435            ctx: &mut better_auth_core::AuthInitContext<S>,
436        ) -> AuthResult<()> {
437            S::User::require_plugin_fields("anonymous", &["is_anonymous"])?;
438            ctx.set_metadata("anonymous.enabled", serde_json::json!(true));
439            Ok(())
440        }
441        async fn before_request(
442            &self,
443            req: &AuthRequest,
444            ctx: &AuthContext<S>,
445        ) -> AuthResult<Option<better_auth_core::BeforeRequestAction>> {
446            if req.path() != "/sign-in/social" { return Ok(None); }
447            better_auth_core::observability::instrumentation::with_endpoint_hook(
448                &ctx.config, req, "before", "plugin:anonymous", async {
449                    let session = ctx.session_manager()
450                        .resolve(req, better_auth_core::session::SessionRead::Authoritative)
451                        .await?.data;
452                    if let Some(session) = session.filter(|session| session.user.is_anonymous == Some(true))
453                        && !session.user.id.is_undefined() {
454                        req.set_server_context("anonymousUserId", session.user.id.field_value())?;
455                    }
456                    Ok(None)
457                },
458            ).await
459        }
460        async fn after_request(
461            &self,
462            req: &AuthRequest,
463            response: &mut AuthResponse,
464            ctx: &AuthContext<S>,
465        ) -> AuthResult<()> {
466if !(["/sign-in","/sign-up","/callback","/magic-link/verify","/email-otp/verify-email","/one-tap/callback","/passkey/verify-authentication","/phone-number/verify","/verify-email"].iter().any(|prefix|req.path().starts_with(prefix))) { return Ok(()); }
467better_auth_core::observability::instrumentation::with_endpoint_hook(
468        &ctx.config, req, "after", "plugin:anonymous", async {
469            self.link(req, response, ctx).await
470         }
471    ).await
472}
473    }
474);