Skip to main content

alibi_api/plugins/
anonymous.rs

1//! Anonymous accounts and their cleanup when a proven account replaces them.
2
3use 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/// Application-owned generation of anonymous display names and email addresses.
18#[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
28/// Both authenticated accounts involved in an anonymous account upgrade.
29pub 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/// Transfer application-owned data before the anonymous account is deleted.
37#[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        // The created row is Source's original new-user snapshot, even if a
210        // lifecycle hook subsequently changes the database during session creation.
211        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            // The new-user callback receives the real newly issued session,
435            // including adapter fields hidden from public session responses.
436            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
482// Source's nested get-session disables refresh, catches failed resolution, and
483// retains its real expiry cleanup/response-cookie effects on the request.
484async 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
531// An ordinary application exception has an empty 500 wire. Explicit typed
532// HTTP errors retain their status/body, including coded and uncoded API errors.
533fn 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}