1use super::helpers::{
4 apply_default_role, completed_response_session, delete_session_cookie_headers, get_cookie,
5 issue_user_session_record, record_completed_session_record, response_has_session_cookie,
6};
7use alibi_core::wire::{SessionView, UserView};
8use alibi_core::{
9 AuthContext, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult, AuthRoute,
10 AuthSchema, AuthSession, AuthUser, BeforeRequestAction, CreateUser, HttpMethod, RequestMeta,
11};
12use async_trait::async_trait;
13use rand::distr::{Alphanumeric, SampleString};
14use serde_json::json;
15use std::sync::Arc;
16
17#[async_trait]
19pub trait AnonymousIdentity: Send + Sync {
20 async fn email(&self) -> AuthResult<Option<String>> {
21 Ok(None)
22 }
23 async fn name(&self, _request: &AuthRequest) -> AuthResult<Option<String>> {
24 Ok(None)
25 }
26}
27
28pub struct AnonymousLink {
30 pub anonymous_user: UserView,
31 pub anonymous_session: SessionView,
32 pub new_user: UserView,
33 pub new_session: SessionView,
34}
35
36#[async_trait]
38pub trait LinkAnonymousAccount: Send + Sync {
39 async fn link(&self, accounts: &AnonymousLink, request: &AuthRequest) -> AuthResult<()>;
40}
41
42#[derive(Clone, Default)]
43pub struct AnonymousConfig {
44 pub email_domain_name: Option<String>,
45 pub identity: Option<Arc<dyn AnonymousIdentity>>,
46 pub on_link_account: Option<Arc<dyn LinkAnonymousAccount>>,
47 pub disable_delete_anonymous_user: bool,
48}
49
50#[derive(Clone, Default)]
51pub struct AnonymousPlugin {
52 config: AnonymousConfig,
53}
54
55impl AnonymousPlugin {
56 #[must_use]
57 pub fn new() -> Self {
58 Self::default()
59 }
60 #[must_use]
61 pub const fn with_config(config: AnonymousConfig) -> Self {
62 Self { config }
63 }
64
65 #[expect(
66 clippy::too_many_lines,
67 reason = "Keep anonymous session reuse, user creation, and session issuance in request order"
68 )]
69 async fn sign_in<S: AuthSchema>(
70 &self,
71 req: &AuthRequest,
72 ctx: &AuthContext<S>,
73 ) -> AuthResult<AuthResponse> {
74 use alibi_core::utils::cookie_utils::{
75 create_session_cookie_with_max_age, create_session_like_cookie, related_cookie_name,
76 sign_cookie_value, verify_cookie_value,
77 };
78
79 if let Some((current, _)) = anonymous_session(req, ctx).await
80 && current.is_anonymous() == Some(true)
81 {
82 return Ok(error(
83 400,
84 "ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY",
85 "Anonymous users cannot sign in again anonymously",
86 ));
87 }
88 let custom = match &self.config.identity {
89 Some(identity) => identity.email().await.map_err(callback_error)?,
90 None => None,
91 };
92 let email = if let Some(email) = custom.filter(|value| !value.is_empty()) {
93 if !super::authentication_helpers::is_valid_email(&email) {
94 return Ok(error(
95 400,
96 "INVALID_EMAIL_FORMAT",
97 "Email was not generated in a valid format",
98 ));
99 }
100 email
101 } else {
102 let id = Alphanumeric.sample_string(&mut rand::rng(), 32);
103 self.config
104 .email_domain_name
105 .as_ref()
106 .filter(|domain| !domain.is_empty())
107 .map_or_else(
108 || format!("{id}@anonymous.placeholder.invalid"),
109 |domain| format!("temp-{id}@{domain}"),
110 )
111 };
112 let name = match &self.config.identity {
113 Some(identity) => identity.name(req).await.map_err(callback_error)?,
114 None => None,
115 }
116 .filter(|name| !name.is_empty())
117 .unwrap_or_else(|| "Anonymous".to_owned());
118 let mut create = CreateUser::new().with_email(email).with_name(name);
119 create.is_anonymous = Some(true);
120 create.email_verified = Some(false);
121 apply_default_role(ctx, &mut create);
122 let user = ctx
123 .database
124 .create_user_with_source_record(
125 create,
126 alibi_core::user_validation::UserValidationSource::creation("anonymous"),
127 )
128 .await
129 .map_err(|error| match error {
130 alibi_core::AuthError::UserCreationCancelled => alibi_core::AuthError::Upstream {
131 status: 500,
132 code: "FAILED_TO_CREATE_USER",
133 message: "Failed to create user",
134 },
135 error @ (alibi_core::AuthError::Api { .. }
136 | alibi_core::AuthError::Upstream { .. }
137 | alibi_core::AuthError::BadRequest(_)
138 | alibi_core::AuthError::InvalidRequest(_)
139 | alibi_core::AuthError::Validation(_)
140 | alibi_core::AuthError::InvalidCredentials
141 | alibi_core::AuthError::Unauthenticated
142 | alibi_core::AuthError::AuthenticationFailed(_)
143 | alibi_core::AuthError::SessionNotFound
144 | alibi_core::AuthError::Forbidden(_)
145 | alibi_core::AuthError::SessionCreationCancelled
146 | alibi_core::AuthError::BannedUser(_)
147 | alibi_core::AuthError::Unauthorized
148 | alibi_core::AuthError::UserNotFound
149 | alibi_core::AuthError::NotFound(_)
150 | alibi_core::AuthError::Conflict(_)
151 | alibi_core::AuthError::MethodNotAllowed(_)
152 | alibi_core::AuthError::PayloadTooLarge(_)
153 | alibi_core::AuthError::UnprocessableEntity(_)
154 | alibi_core::AuthError::RateLimited { .. }
155 | alibi_core::AuthError::NotImplemented(_)
156 | alibi_core::AuthError::Config(_)
157 | alibi_core::AuthError::Database(_)
158 | alibi_core::AuthError::Serialization(_)
159 | alibi_core::AuthError::Plugin { .. }
160 | alibi_core::AuthError::CallbackFailure(_)
161 | alibi_core::AuthError::Internal(_)
162 | alibi_core::AuthError::Encryption(_)
163 | alibi_core::AuthError::PasswordHash(_)
164 | alibi_core::AuthError::Jwt(_)) => error,
165 })?;
166 let meta = RequestMeta::from_request(req);
167 let issued =
168 issue_user_session_record(ctx, user.id().as_ref(), meta.ip_address, meta.user_agent)
169 .await
170 .map_err(|cause| match cause.into_auth_error() {
171 alibi_core::AuthError::SessionCreationCancelled => {
172 alibi_core::AuthError::Upstream {
173 status: 400,
174 code: "COULD_NOT_CREATE_SESSION",
175 message: "Could not create session",
176 }
177 }
178 cause @ (alibi_core::AuthError::Api { .. }
179 | alibi_core::AuthError::Upstream { .. }
180 | alibi_core::AuthError::BadRequest(_)
181 | alibi_core::AuthError::InvalidRequest(_)
182 | alibi_core::AuthError::Validation(_)
183 | alibi_core::AuthError::InvalidCredentials
184 | alibi_core::AuthError::Unauthenticated
185 | alibi_core::AuthError::AuthenticationFailed(_)
186 | alibi_core::AuthError::SessionNotFound
187 | alibi_core::AuthError::Forbidden(_)
188 | alibi_core::AuthError::UserCreationCancelled
189 | alibi_core::AuthError::BannedUser(_)
190 | alibi_core::AuthError::Unauthorized
191 | alibi_core::AuthError::UserNotFound
192 | alibi_core::AuthError::NotFound(_)
193 | alibi_core::AuthError::Conflict(_)
194 | alibi_core::AuthError::MethodNotAllowed(_)
195 | alibi_core::AuthError::PayloadTooLarge(_)
196 | alibi_core::AuthError::UnprocessableEntity(_)
197 | alibi_core::AuthError::RateLimited { .. }
198 | alibi_core::AuthError::NotImplemented(_)
199 | alibi_core::AuthError::Config(_)
200 | alibi_core::AuthError::Database(_)
201 | alibi_core::AuthError::Serialization(_)
202 | alibi_core::AuthError::Plugin { .. }
203 | alibi_core::AuthError::CallbackFailure(_)
204 | alibi_core::AuthError::Internal(_)
205 | alibi_core::AuthError::Encryption(_)
206 | alibi_core::AuthError::PasswordHash(_)
207 | alibi_core::AuthError::Jwt(_)) => cause,
208 })?;
209 record_completed_session_record::<S>(&user, &issued.session);
212 let mut response = AuthResponse::json(
213 200,
214 &json!({"token":issued.session.token(),"user":ctx.user_view(&user)}),
215 )?;
216
217 let preference_name = related_cookie_name(&ctx.config, "dont_remember");
218 let dont_remember = get_cookie(req, &preference_name)
219 .and_then(|value| verify_cookie_value(&value, ctx.config.current_secret()))
220 .is_some_and(|value| !value.is_empty());
221 response.headers.append(
222 "set-cookie",
223 create_session_cookie_with_max_age(
224 Some(issued.session.token()),
225 if dont_remember {
226 None
227 } else {
228 Some(ctx.config.session.expires_in.num_seconds())
229 },
230 &ctx.config,
231 )?,
232 );
233 if dont_remember {
234 response.headers.append(
235 "set-cookie",
236 create_session_like_cookie(
237 &preference_name,
238 &sign_cookie_value("true", ctx.config.current_secret()),
239 None,
240 &ctx.config,
241 )?,
242 );
243 }
244 Ok(response)
245 }
246
247 async fn delete(
248 &self,
249 req: &AuthRequest,
250 ctx: &AuthContext<impl AuthSchema>,
251 ) -> AuthResult<AuthResponse> {
252 let (user, _) =
253 ctx.require_authoritative_session(req)
254 .await
255 .map_err(|error| match error {
256 alibi_core::AuthError::Unauthenticated => alibi_core::AuthError::Upstream {
257 status: 401,
258 code: "UNAUTHORIZED",
259 message: "Unauthorized",
260 },
261 error @ (alibi_core::AuthError::Api { .. }
262 | alibi_core::AuthError::Upstream { .. }
263 | alibi_core::AuthError::BadRequest(_)
264 | alibi_core::AuthError::InvalidRequest(_)
265 | alibi_core::AuthError::Validation(_)
266 | alibi_core::AuthError::InvalidCredentials
267 | alibi_core::AuthError::AuthenticationFailed(_)
268 | alibi_core::AuthError::SessionNotFound
269 | alibi_core::AuthError::Forbidden(_)
270 | alibi_core::AuthError::SessionCreationCancelled
271 | alibi_core::AuthError::UserCreationCancelled
272 | alibi_core::AuthError::BannedUser(_)
273 | alibi_core::AuthError::Unauthorized
274 | alibi_core::AuthError::UserNotFound
275 | alibi_core::AuthError::NotFound(_)
276 | alibi_core::AuthError::Conflict(_)
277 | alibi_core::AuthError::MethodNotAllowed(_)
278 | alibi_core::AuthError::PayloadTooLarge(_)
279 | alibi_core::AuthError::UnprocessableEntity(_)
280 | alibi_core::AuthError::RateLimited { .. }
281 | alibi_core::AuthError::NotImplemented(_)
282 | alibi_core::AuthError::Config(_)
283 | alibi_core::AuthError::Database(_)
284 | alibi_core::AuthError::Serialization(_)
285 | alibi_core::AuthError::Plugin { .. }
286 | alibi_core::AuthError::CallbackFailure(_)
287 | alibi_core::AuthError::Internal(_)
288 | alibi_core::AuthError::Encryption(_)
289 | alibi_core::AuthError::PasswordHash(_)
290 | alibi_core::AuthError::Jwt(_)) => error,
291 })?;
292 if self.config.disable_delete_anonymous_user {
293 return Ok(error(
294 400,
295 "DELETE_ANONYMOUS_USER_DISABLED",
296 "Deleting anonymous users is disabled",
297 ));
298 }
299 if user.is_anonymous() != Some(true) {
300 return Ok(error(403, "USER_IS_NOT_ANONYMOUS", "User is not anonymous"));
301 }
302 if let Err(cause) = ctx.database.delete_user_sessions(user.id().as_ref()).await {
303 tracing::error!(%cause, "Failed to delete anonymous user sessions");
304 return Ok(error(
305 500,
306 "FAILED_TO_DELETE_ANONYMOUS_USER_SESSIONS",
307 "Failed to delete anonymous user sessions",
308 ));
309 }
310 if let Err(cause) = ctx.database.delete_user(user.id().as_ref()).await {
311 tracing::error!(%cause, "Failed to delete anonymous user");
312 return Ok(error(
313 500,
314 "FAILED_TO_DELETE_ANONYMOUS_USER",
315 "Failed to delete anonymous user",
316 ));
317 }
318 let mut response = AuthResponse::json(200, &json!({"success":true}))?;
319 for cookie in delete_session_cookie_headers(&ctx.config)? {
320 response.headers.append("set-cookie", cookie);
321 }
322 Ok(response)
323 }
324}
325
326#[async_trait]
327impl<S: AuthSchema> AuthPlugin<S> for AnonymousPlugin {
328 fn static_openapi_metadata(&self) -> alibi_core::PluginOpenApiMetadata {
329 crate::metadata::plugin_metadata(
330 <Self as alibi_core::AuthPlugin<S>>::name(self),
331 &<Self as alibi_core::AuthPlugin<S>>::routes(self),
332 )
333 }
334
335 fn openapi_metadata(
336 &self,
337 ctx: &alibi_core::AuthInitContext<S>,
338 ) -> alibi_core::PluginOpenApiMetadata {
339 crate::metadata::instance_plugin_metadata(
340 <Self as alibi_core::AuthPlugin<S>>::name(self),
341 &<Self as alibi_core::AuthPlugin<S>>::routes(self),
342 ctx,
343 )
344 }
345
346 fn name(&self) -> &'static str {
347 "anonymous"
348 }
349 fn routes(&self) -> Vec<AuthRoute> {
350 vec![
351 AuthRoute::post("/sign-in/anonymous", "sign_in_anonymous"),
352 AuthRoute::post("/delete-anonymous-user", "delete_anonymous_user"),
353 ]
354 }
355 async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
356 ctx.set_metadata("anonymous.enabled", json!(true));
357 let default = |mut input: CreateUser| {
358 if input.is_anonymous.is_none() {
359 input.is_anonymous = Some(false);
360 }
361 Ok(input)
362 };
363 if ctx.config.user_validation.is_some() {
364 ctx.register_user_creation_adapter_default(default);
365 } else {
366 ctx.register_user_create_transform(default);
367 }
368 Ok(())
369 }
370 async fn on_request(
371 &self,
372 req: &AuthRequest,
373 ctx: &AuthContext<S>,
374 ) -> AuthResult<Option<AuthResponse>> {
375 match (req.method(), req.path()) {
376 (HttpMethod::Post, "/sign-in/anonymous") => Ok(Some(self.sign_in(req, ctx).await?)),
377 (HttpMethod::Post, "/delete-anonymous-user") => Ok(Some(self.delete(req, ctx).await?)),
378 _ => Ok(None),
379 }
380 }
381 async fn before_request(
382 &self,
383 req: &AuthRequest,
384 ctx: &AuthContext<S>,
385 ) -> AuthResult<Option<BeforeRequestAction>> {
386 if req.path() == "/sign-in/social"
387 && let Some((user, _)) = anonymous_session(req, ctx).await
388 && user.is_anonymous() == Some(true)
389 {
390 req.extensions()
391 .insert(super::oauth::CapturedOAuthServerContext(
392 super::oauth::OAuthServerContext {
393 anonymous_user_id: user.id().into_owned(),
394 },
395 ));
396 }
397 Ok(None)
398 }
399 async fn after_request(
400 &self,
401 req: &AuthRequest,
402 ctx: &AuthContext<S>,
403 response: AuthResponse,
404 ) -> AuthResult<AuthResponse> {
405 let matches = [
406 "/sign-in",
407 "/sign-up",
408 "/callback",
409 "/magic-link/verify",
410 "/email-otp/verify-email",
411 "/one-tap/callback",
412 "/passkey/verify-authentication",
413 "/phone-number/verify",
414 "/verify-email",
415 ]
416 .iter()
417 .any(|prefix| req.path().starts_with(prefix));
418 if !matches {
419 return Ok(response);
420 }
421 if !response_has_session_cookie(ctx, &response) {
422 return Ok(response);
423 }
424 let Some((old_user, old_session)) = resolve_anonymous_session(req, ctx).await? else {
425 return Ok(response);
426 };
427 if old_user.is_anonymous != Some(true) {
428 return Ok(response);
429 }
430 let Some(issued) = completed_response_session(req, ctx, &response) else {
431 return Ok(response);
432 };
433 if let Some(linker) = &self.config.on_link_account {
434 let mut new_session = issued.callback_session(ctx);
437 new_session.omitted_fields.clear();
438 linker
439 .link(
440 &AnonymousLink {
441 anonymous_user: old_user.clone(),
442 anonymous_session: ctx.session_view(&old_session),
443 new_user: issued.callback_user(ctx),
444 new_session,
445 },
446 req,
447 )
448 .await
449 .map_err(callback_error)?;
450 }
451 if self.config.disable_delete_anonymous_user
452 || old_user.id == issued.user.id().as_ref()
453 || issued.user.is_anonymous() == Some(true)
454 {
455 return Ok(response);
456 }
457 if let Err(error) = ctx.database.delete_user(&old_user.id).await {
458 tracing::error!(user_id=%old_user.id,error=%error,"Failed to clean up anonymous account");
459 }
460 Ok(response)
461 }
462}
463
464impl std::fmt::Debug for AnonymousConfig {
465 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
466 f.debug_struct("AnonymousConfig").finish_non_exhaustive()
467 }
468}
469
470impl std::fmt::Debug for AnonymousLink {
471 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
472 f.debug_struct("AnonymousLink").finish_non_exhaustive()
473 }
474}
475
476impl std::fmt::Debug for AnonymousPlugin {
477 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478 f.debug_struct("AnonymousPlugin").finish_non_exhaustive()
479 }
480}
481
482async fn anonymous_session<S: AuthSchema>(
485 req: &AuthRequest,
486 ctx: &AuthContext<S>,
487) -> Option<(alibi_core::AuthenticatedUser<S>, SessionView)> {
488 let mut read = req.clone();
489 drop(read.query.insert("disableRefresh".into(), "true".into()));
490 ctx.require_cached_session(&read).await.ok()
491}
492
493async fn resolve_anonymous_session<S: AuthSchema>(
494 req: &AuthRequest,
495 ctx: &AuthContext<S>,
496) -> AuthResult<Option<(UserView, SessionView)>> {
497 if let Some((user, session)) = anonymous_session(req, ctx).await
498 && user.is_anonymous() == Some(true)
499 {
500 let user = match user {
501 alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(&user),
502 alibi_core::AuthenticatedUser::Cached(user) => *user,
503 };
504 return Ok(Some((user, session)));
505 }
506 let Some(context) = req
507 .extensions()
508 .get::<super::oauth::RecoveredOAuthServerContext>()
509 else {
510 return Ok(None);
511 };
512 let Some(user) = ctx
513 .database
514 .get_user_by_id_record(&context.0.anonymous_user_id)
515 .await?
516 else {
517 return Ok(None);
518 };
519 if user.is_anonymous() != Some(true) {
520 return Ok(None);
521 }
522 let session = ctx
523 .database
524 .get_user_sessions(&context.0.anonymous_user_id)
525 .await?
526 .into_iter()
527 .find(|session| session.expires_at() > chrono::Utc::now());
528 Ok(session.map(|session| (ctx.trusted_user_view(&user), ctx.session_view(&session))))
529}
530
531fn callback_error(error: alibi_core::AuthError) -> alibi_core::AuthError {
534 match error {
535 alibi_core::AuthError::Internal(_) => {
536 alibi_core::AuthError::CallbackFailure(Box::new(error))
537 }
538 other => other,
539 }
540}
541
542fn error(status: u16, code: &'static str, message: &'static str) -> AuthResponse {
543 alibi_core::AuthError::Upstream {
544 status,
545 code,
546 message,
547 }
548 .to_auth_response()
549}