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#[derive(Debug, Clone)]
23pub struct AnonymousLink {
24 pub anonymous_user: UserView,
26 pub anonymous_session: SessionView,
28 pub new_user: better_auth_core::FieldValue,
30 pub new_session: SessionView,
32 pub request: AuthRequest,
34}
35
36#[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 pub fn new() -> Self {
49 Self::default()
50 }
51 pub fn email_domain_name(mut self, domain: impl Into<String>) -> Self {
53 self.email_domain_name = Some(domain.into());
54 self
55 }
56 pub fn disable_delete_anonymous_user(mut self, disable: bool) -> Self {
58 self.disable_delete_anonymous_user = disable;
59 self
60 }
61 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 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 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 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);