alibi 0.10.0

Authentication for Rust, compatible with Better Auth’s TypeScript client
Documentation
use super::Alibi;
use super::endpoint::merge_headers;
use alibi_core::endpoint::{
    BeforeEndpointAction, EndpointCall, EndpointContextPatch, EndpointResponse,
    is_endpoint_api_error, with_endpoint_call_context,
};
use alibi_core::{AuthError, AuthSchema, Headers};
use std::sync::Arc;

type HttpEndpointError = (u16, Option<String>, String);

/// Frames retained by the real HTTP endpoint pipeline for configured hooks.
#[derive(Clone)]
pub(super) struct HttpEndpointFrame {
    pub call: EndpointCall,
    pub outer: EndpointCall,
    pub path_patch: Option<String>,
    pub header_patch: Option<std::collections::HashMap<String, String>>,
    pub legacy_headers: Option<std::collections::HashMap<String, String>>,
    pub error: Arc<std::sync::Mutex<Option<HttpEndpointError>>>,
    pub returned_response: Arc<std::sync::Mutex<Option<alibi_core::AuthResponse>>>,
}

/// HTTP callbacks keep live dispatch state separate from a frame's owned
/// optional Request. Storing that physical context in the frame itself would
/// make its extension map own itself through `HttpEndpointFrame`.
pub(super) fn with_http_endpoint_call_context<T>(
    call: EndpointCall,
    future: impl Future<Output = T>,
) -> impl Future<Output = T> {
    let request = alibi_core::hooks::current_request_hook_context();
    // Bound the HTTP dispatch future without increasing callers' stack sizes.
    Box::pin(with_endpoint_call_context(
        call,
        alibi_core::hooks::with_optional_request_hook_context(request, future),
    ))
}

impl<S: AuthSchema> Alibi<S> {
    pub(super) async fn before_http_endpoint(
        &self,
        request: &mut alibi_core::AuthRequest,
        route: &alibi_core::AuthRoute,
        context: &alibi_core::AuthContext<S>,
    ) -> alibi_core::AuthResult<Option<alibi_core::AuthResponse>> {
        // Retain the logical frame even without application endpoint hooks:
        // handlers publish renewed session cookies into this frame's extensions.
        let mut call = EndpointCall::from_http_request(request, route);
        call.extensions().insert((*context.config).clone());
        let mut outer = call.clone();
        let mut patch = EndpointContextPatch::default();
        let mut headers = Headers::new();
        for hook in &self.endpoint_hooks {
            let matches = with_http_endpoint_call_context(call.clone(), async { hook.matches_before(&call, context) }).await.map_err(|error| {
                tracing::error!(%error, "Endpoint before-hook matcher failed");
                AuthError::Api { status: 500, code: None, message: "An error occurred during hook matcher execution. Check the logs for more details.".into() }
            })?;
            if !matches {
                continue;
            }
            let middleware = call.middleware_context();
            let action =
                with_http_endpoint_call_context(call.clone(), hook.before(&middleware, context))
                    .await;
            let emitted = call.take_response_headers();
            let action = match action {
                Ok(action) => action,
                Err(error) if is_endpoint_api_error(&error) => {
                    let mut response = error.to_auth_response();
                    merge_headers(&mut response.headers, emitted);
                    return Ok(Some(response));
                }
                Err(error) => return Err(ordinary_http_hook_error(error)),
            };
            let hook_headers = emitted.clone();
            merge_headers(&mut headers, emitted);
            match action {
                Some(BeforeEndpointAction::Patch(changes)) => patch.merge(*changes),
                Some(BeforeEndpointAction::Respond(mut response)) => {
                    response.merge_headers(headers);
                    return render_http_endpoint_response(&response).map(Some);
                }
                Some(BeforeEndpointAction::Reject(mut response)) => {
                    response.merge_headers(hook_headers);
                    return render_http_endpoint_response(&response).map(Some);
                }
                None => {}
            }
        }
        let path_patch = patch.path.clone();
        let header_patch = patch.headers.clone();
        patch.apply(&mut call);
        EndpointContextPatch {
            headers: call.headers().cloned(),
            ..Default::default()
        }
        .apply(&mut outer);
        for (name, value) in headers {
            request.queue_response_header(name, value);
        }
        request.extensions().insert(HttpEndpointFrame {
            call,
            outer,
            path_patch,
            header_patch,
            legacy_headers: None,
            error: Arc::default(),
            returned_response: Arc::default(),
        });
        Ok(None)
    }

    pub(super) async fn after_http_endpoint(
        &self,
        request: &alibi_core::AuthRequest,
        context: &alibi_core::AuthContext<S>,
        mut response: alibi_core::AuthResponse,
    ) -> alibi_core::AuthResult<alibi_core::AuthResponse> {
        let Some(frame) = request.extensions().get::<HttpEndpointFrame>() else {
            return Ok(response);
        };
        if let Some((user, session)) = request.session_hook_snapshot() {
            frame
                .call
                .record_authenticated_session(user.clone(), session.clone());
            frame.call.set_session_hook_snapshot(user, session);
        }
        let original =
            alibi_core::utils::json::from_slice::<alibi_core::utils::json::JsValue>(&response.body)
                .unwrap_or(alibi_core::utils::json::JsValue::Null);
        let returned_response = frame
            .returned_response
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .clone();
        let mut logical = if let Some(raw) = returned_response.as_ref() {
            EndpointResponse::from_http_response(raw.clone())
        } else if let Some((status, code, message)) = frame
            .error
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .clone()
        {
            EndpointResponse::error(AuthError::Api {
                status,
                code,
                message,
            })
            .with_error_body(original.clone())
        } else {
            EndpointResponse::value(original.clone()).with_status(response.status)
        };
        let headers = std::mem::take(&mut response.headers);
        if let Some(raw) = returned_response.as_ref() {
            let mut metadata = Headers::new();
            for (name, value) in headers {
                if !raw.headers.iter().any(|(raw_name, raw_value)| {
                    raw_name.eq_ignore_ascii_case(&name) && raw_value == &value
                }) {
                    metadata.append(name, value);
                }
            }
            logical.merge_headers(metadata);
        } else {
            logical.merge_headers(headers);
        }
        for hook in &self.endpoint_hooks {
            if !with_http_endpoint_call_context(frame.outer.clone(), async {
                hook.matches_after(&frame.call, context, &logical)
            })
            .await
            .map_err(|error| {
                if is_endpoint_api_error(&error) {
                    error
                } else {
                    ordinary_http_hook_error(error)
                }
            })? {
                continue;
            }
            let middleware = frame.call.middleware_context();
            let prior_headers = logical.headers().clone();
            let status = logical.status();
            logical = match with_http_endpoint_call_context(
                frame.outer.clone(),
                hook.after(&middleware, context, logical),
            )
            .await
            {
                Ok(response) => response,
                Err(error) if is_endpoint_api_error(&error) => {
                    let mut response = EndpointResponse::error(error);
                    response.set_status(status);
                    response.merge_headers(prior_headers);
                    response
                }
                Err(error) => return Err(ordinary_http_hook_error(error)),
            };
            logical.merge_headers(frame.call.take_response_headers());
        }
        // Plugin HTTP response hooks run on the physical request, not the
        // logical frame where the handler completed its session publication.
        alibi_core::session::cookie_cache::runtime::retain_publication(&frame.call, request);
        if matches!(logical.result(), Ok(value) if value == &original) {
            response.status = logical.status().unwrap_or(response.status);
            response.headers = logical.headers().clone();
            Ok(response)
        } else {
            render_http_endpoint_response(&logical)
        }
    }
}

pub(super) fn apply_http_endpoint_input(
    request: &mut alibi_core::AuthRequest,
) -> alibi_core::AuthResult<()> {
    let Some(frame) = request.extensions().get::<HttpEndpointFrame>() else {
        return Ok(());
    };
    let mut frame = (*frame).clone();
    if let Some(headers) = &frame.header_patch {
        request.headers.extend(headers.clone());
    }
    if let Some(headers) = &frame.legacy_headers {
        request.headers.extend(headers.clone());
    }
    frame.call.replace_headers(request.headers.clone());
    EndpointContextPatch {
        headers: Some(request.headers.clone()),
        ..Default::default()
    }
    .apply(&mut frame.outer);
    let call = &frame.call;
    // Logical hook paths may be route templates; only explicit patches change dispatch input.
    if let Some(path) = &frame.path_patch {
        path.clone_into(&mut request.path);
    }
    if let Some(method) = call.method() {
        request.method = method.clone();
    }
    if let Some(body) = call.body() {
        request.body = Some(alibi_core::utils::json::to_vec(body)?);
        request
            .extensions()
            .insert(alibi_core::types::ParsedRequestBody::Value(body.clone()));
    }
    if let Some(alibi_core::utils::json::JsValue::Object(query)) = call.query() {
        request.set_query_pairs(query.iter().flat_map(|(name, value)| {
            let values = match value {
                alibi_core::utils::json::JsValue::Array(values) => values.clone(),
                value => vec![value.clone()],
            };
            values.into_iter().map(|value| {
                (
                    name.clone(),
                    match value {
                        alibi_core::utils::json::JsValue::String(value) => value,
                        value => alibi_core::utils::json::to_string(&value).unwrap_or_default(),
                    },
                )
            })
        }));
    }
    request.extensions().insert(frame);
    Ok(())
}

fn ordinary_http_hook_error(error: AuthError) -> AuthError {
    match error {
        AuthError::CallbackFailure(_) => error,
        error => AuthError::CallbackFailure(Box::new(error)),
    }
}

fn render_http_endpoint_response(
    response: &EndpointResponse,
) -> alibi_core::AuthResult<alibi_core::AuthResponse> {
    if response.result().is_ok() {
        return response.to_response();
    }
    let mut rendered = match response.result() {
        Ok(value) => alibi_core::AuthResponse::json(response.status().unwrap_or(200), value)?,
        Err(error) => {
            let (status, code, message) = error.error_payload();
            let mut rendered = alibi_core::AuthResponse::json(
                status,
                &alibi_core::ErrorCodeMessageResponse { code, message },
            )?;
            if response.error_body_is_absent() {
                rendered.body.clear();
            } else if let Some(body) = response.error_body() {
                rendered.body = alibi_core::utils::json::to_vec(body)?;
            }
            rendered
        }
    };
    merge_headers(&mut rendered.headers, response.headers().clone());
    Ok(rendered)
}