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