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