Skip to main content

alibi_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    route_openapi_metadata!(S);
329
330    fn name(&self) -> &'static str {
331        "anonymous"
332    }
333    fn routes(&self) -> Vec<AuthRoute> {
334        vec![
335            AuthRoute::post("/sign-in/anonymous", "sign_in_anonymous"),
336            AuthRoute::post("/delete-anonymous-user", "delete_anonymous_user"),
337        ]
338    }
339    async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
340        ctx.set_metadata("anonymous.enabled", json!(true));
341        let default = |mut input: CreateUser| {
342            if input.is_anonymous.is_none() {
343                input.is_anonymous = Some(false);
344            }
345            Ok(input)
346        };
347        if ctx.config.user_validation.is_some() {
348            ctx.register_user_creation_adapter_default(default);
349        } else {
350            ctx.register_user_create_transform(default);
351        }
352        Ok(())
353    }
354    async fn on_request(
355        &self,
356        req: &AuthRequest,
357        ctx: &AuthContext<S>,
358    ) -> AuthResult<Option<AuthResponse>> {
359        match (req.method(), req.path()) {
360            (HttpMethod::Post, "/sign-in/anonymous") => Ok(Some(self.sign_in(req, ctx).await?)),
361            (HttpMethod::Post, "/delete-anonymous-user") => Ok(Some(self.delete(req, ctx).await?)),
362            _ => Ok(None),
363        }
364    }
365    async fn before_request(
366        &self,
367        req: &AuthRequest,
368        ctx: &AuthContext<S>,
369    ) -> AuthResult<Option<BeforeRequestAction>> {
370        if req.path() == "/sign-in/social"
371            && let Some((user, _)) = anonymous_session(req, ctx).await
372            && user.is_anonymous() == Some(true)
373        {
374            req.extensions()
375                .insert(super::oauth::CapturedOAuthServerContext(
376                    super::oauth::OAuthServerContext {
377                        anonymous_user_id: user.id().into_owned(),
378                    },
379                ));
380        }
381        Ok(None)
382    }
383    async fn after_request(
384        &self,
385        req: &AuthRequest,
386        ctx: &AuthContext<S>,
387        response: AuthResponse,
388    ) -> AuthResult<AuthResponse> {
389        let matches = [
390            "/sign-in",
391            "/sign-up",
392            "/callback",
393            "/magic-link/verify",
394            "/email-otp/verify-email",
395            "/one-tap/callback",
396            "/passkey/verify-authentication",
397            "/phone-number/verify",
398            "/verify-email",
399        ]
400        .iter()
401        .any(|prefix| req.path().starts_with(prefix));
402        if !matches {
403            return Ok(response);
404        }
405        if !response_has_session_cookie(ctx, &response) {
406            return Ok(response);
407        }
408        let Some((old_user, old_session)) = resolve_anonymous_session(req, ctx).await? else {
409            return Ok(response);
410        };
411        if old_user.is_anonymous != Some(true) {
412            return Ok(response);
413        }
414        let Some(issued) = completed_response_session(req, ctx, &response) else {
415            return Ok(response);
416        };
417        if let Some(linker) = &self.config.on_link_account {
418            // The new-user callback receives the real newly issued session,
419            // including adapter fields hidden from public session responses.
420            let mut new_session = issued.callback_session(ctx);
421            new_session.omitted_fields.clear();
422            linker
423                .link(
424                    &AnonymousLink {
425                        anonymous_user: old_user.clone(),
426                        anonymous_session: ctx.session_view(&old_session),
427                        new_user: issued.callback_user(ctx),
428                        new_session,
429                    },
430                    req,
431                )
432                .await
433                .map_err(callback_error)?;
434        }
435        if self.config.disable_delete_anonymous_user
436            || old_user.id == issued.user.id().as_ref()
437            || issued.user.is_anonymous() == Some(true)
438        {
439            return Ok(response);
440        }
441        if let Err(error) = ctx.database.delete_user(&old_user.id).await {
442            tracing::error!(user_id=%old_user.id,error=%error,"Failed to clean up anonymous account");
443        }
444        Ok(response)
445    }
446}
447
448impl std::fmt::Debug for AnonymousConfig {
449    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        f.debug_struct("AnonymousConfig").finish_non_exhaustive()
451    }
452}
453
454impl std::fmt::Debug for AnonymousLink {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        f.debug_struct("AnonymousLink").finish_non_exhaustive()
457    }
458}
459
460impl std::fmt::Debug for AnonymousPlugin {
461    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
462        f.debug_struct("AnonymousPlugin").finish_non_exhaustive()
463    }
464}
465
466// Source's nested get-session disables refresh, catches failed resolution, and
467// retains its real expiry cleanup/response-cookie effects on the request.
468async fn anonymous_session<S: AuthSchema>(
469    req: &AuthRequest,
470    ctx: &AuthContext<S>,
471) -> Option<(alibi_core::AuthenticatedUser<S>, SessionView)> {
472    let mut read = req.clone();
473    _ = read.query.insert("disableRefresh".into(), "true".into());
474    ctx.require_cached_session(&read).await.ok()
475}
476
477async fn resolve_anonymous_session<S: AuthSchema>(
478    req: &AuthRequest,
479    ctx: &AuthContext<S>,
480) -> AuthResult<Option<(UserView, SessionView)>> {
481    if let Some((user, session)) = anonymous_session(req, ctx).await
482        && user.is_anonymous() == Some(true)
483    {
484        let user = match user {
485            alibi_core::AuthenticatedUser::Stored(user) => ctx.user_view(&user),
486            alibi_core::AuthenticatedUser::Cached(user) => *user,
487        };
488        return Ok(Some((user, session)));
489    }
490    let Some(context) = req
491        .extensions()
492        .get::<super::oauth::RecoveredOAuthServerContext>()
493    else {
494        return Ok(None);
495    };
496    let Some(user) = ctx
497        .database
498        .get_user_by_id_record(&context.0.anonymous_user_id)
499        .await?
500    else {
501        return Ok(None);
502    };
503    if user.is_anonymous() != Some(true) {
504        return Ok(None);
505    }
506    let session = ctx
507        .database
508        .get_user_sessions(&context.0.anonymous_user_id)
509        .await?
510        .into_iter()
511        .find(|session| session.expires_at() > chrono::Utc::now());
512    Ok(session.map(|session| (ctx.trusted_user_view(&user), ctx.session_view(&session))))
513}
514
515// An ordinary application exception has an empty 500 wire. Explicit typed
516// HTTP errors retain their status/body, including coded and uncoded API errors.
517fn callback_error(error: alibi_core::AuthError) -> alibi_core::AuthError {
518    match error {
519        alibi_core::AuthError::Internal(_) => {
520            alibi_core::AuthError::CallbackFailure(Box::new(error))
521        }
522        other => other,
523    }
524}
525
526fn error(status: u16, code: &'static str, message: &'static str) -> AuthResponse {
527    alibi_core::AuthError::Upstream {
528        status,
529        code,
530        message,
531    }
532    .to_auth_response()
533}