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: Option<&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
55/// Authority created for an anonymous identity by the public server operation.
56#[derive(Debug, serde::Deserialize)]
57pub struct AnonymousSignIn {
58    pub token: String,
59    pub user: Box<UserView>,
60}
61
62/// The authenticated anonymous identity and its sessions were deleted.
63#[derive(Debug, serde::Deserialize)]
64pub struct AnonymousDeleted {
65    pub success: bool,
66}
67
68impl AnonymousPlugin {
69    /// Delete only the authoritative caller's anonymous account.
70    #[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    /// Create anonymous authority through installed server endpoint hooks.
77    #[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        // The created row is Source's original new-user snapshot, even if a
262        // lifecycle hook subsequently changes the database during session creation.
263        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            // The new-user callback receives the real newly issued session,
518            // including adapter fields hidden from public session responses.
519            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
565// Source's nested get-session disables refresh, catches failed resolution, and
566// retains its real expiry cleanup/response-cookie effects on the request.
567async 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
632// An ordinary application exception has an empty 500 wire. Explicit typed
633// HTTP errors retain their status/body, including coded and uncoded API errors.
634fn 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}