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