alibi-plugins 0.4.1

Built-in authentication plugins and API metadata for Alibi
Documentation
use crate::authentication_helpers::is_valid_email;
use alibi_core::{AuthRequest, AuthResponse, types::ParsedRequestBody};
use serde_json::{Map, Value, json};

#[derive(Debug)]
pub(super) struct VerifyBody {
    pub message: String,
    pub signature: String,
    pub email: Option<String>,
}

fn error(message: &str) -> AuthResponse {
    AuthResponse::json(400, &json!({"message":message,"code":"VALIDATION_ERROR"}))
        .unwrap_or_else(|_| AuthResponse::text(400, "Invalid request body"))
}

const fn received(value: Option<&Value>) -> &'static str {
    match value {
        None => "undefined",
        Some(Value::Null) => "null",
        Some(Value::Array(_)) => "array",
        Some(Value::Object(_)) => "object",
        Some(Value::Bool(_)) => "boolean",
        Some(Value::String(_)) => "string",
        Some(Value::Number(_)) => "number",
    }
}

fn body(request: &AuthRequest, optional: bool) -> Result<Map<String, Value>, AuthResponse> {
    if request.body.is_some() {
        let content_type = request
            .header("content-type")
            .map(String::as_str)
            .unwrap_or_default();
        let normalized = content_type.to_ascii_lowercase();
        let media_type = normalized.split(';').next().unwrap_or_default().trim();
        // Better Call checks the explicit endpoint allowlist before decoding any
        // body. Its matching rule permits a containing media type as well.
        if !media_type.contains("application/json") {
            let message = if normalized.is_empty() {
                "Content-Type is required. Allowed types: application/json".to_owned()
            } else {
                format!(
                    "Content-Type \"{content_type}\" is not allowed. Allowed types: application/json"
                )
            };
            return Err(AuthResponse::json(
                415,
                &json!({"message":message,"code":"UNSUPPORTED_MEDIA_TYPE"}),
            )
            .unwrap_or_else(|_| AuthResponse::text(415, "Unsupported media type")));
        }
    }
    // Use the transport's decoded value rather than interpreting every admitted
    // media type as JSON bytes. ArrayBuffer exposes no schema fields. Other
    // opaque transport bodies are rejected without synthesizing runtime objects.
    if let Some(decoded) = request.extensions().get::<ParsedRequestBody>()
        && let ParsedRequestBody::Opaque(kind) = &*decoded
    {
        return if *kind == "ArrayBuffer" {
            Ok(Map::new())
        } else {
            Err(error(&format!(
                "[body] Invalid input: expected object, received {kind}"
            )))
        };
    }
    let parsed = request
        .body
        .as_ref()
        .map(|_| request.body_as_json::<Value>())
        .transpose()
        .map_err(|_error| {
            AuthResponse::json(
                400,
                &json!({"message":"Invalid JSON in request body","code":"BAD_REQUEST"}),
            )
            .unwrap_or_else(|_| AuthResponse::text(400, "Invalid JSON in request body"))
        })?;
    match parsed {
        None if optional => Ok(Map::new()),
        Some(Value::Object(body)) => Ok(body),
        value => Err(error(
            &(format!(
                "[body] Invalid input: expected object, received {}",
                received(value.as_ref())
            )),
        )),
    }
}

fn unknown_keys(body: &Map<String, Value>, known: &[&str]) -> Option<String> {
    let mut keys: Vec<&str> = body
        .keys()
        .map(String::as_str)
        .filter(|key| !known.contains(key))
        .collect();
    // JavaScript enumerates canonical numeric indexes first. The stable sort
    // retains insertion order for ordinary property names.
    keys.sort_by_key(|key| {
        key.parse::<u32>()
            .ok()
            .filter(|index| *index != u32::MAX && index.to_string() == *key)
            .map_or((1, 0), |index| (0, index))
    });
    if keys.is_empty() {
        return None;
    }
    let formatted = keys
        .iter()
        .map(|key| format!("\"{key}\""))
        .collect::<Vec<_>>()
        .join(", ");
    Some(format!(
        "[body] Unrecognized key{}: {formatted}",
        if keys.len() == 1 { "" } else { "s" }
    ))
}

pub(super) fn nonce_body(request: &AuthRequest) -> Result<(), AuthResponse> {
    let body = body(request, true)?;
    if let Some(message) = unknown_keys(&body, &[]) {
        return Err(error(&(message)));
    }
    Ok(())
}

pub(super) fn verify_body(
    request: &AuthRequest,
    anonymous: bool,
) -> Result<VerifyBody, AuthResponse> {
    let body = body(request, false)?;
    let mut errors = Vec::new();
    let mut aborted = false;
    for field in ["message", "signature"] {
        match body.get(field) {
            Some(Value::String(value)) if value.is_empty() => errors.push(format!(
                "[body.{field}] Too small: expected string to have >=1 characters"
            )),
            Some(Value::String(_)) => {}
            value => {
                aborted = true;
                errors.push(format!(
                    "[body.{field}] Invalid input: expected string, received {}",
                    received(value)
                ));
            }
        }
    }
    match body.get("email") {
        Some(Value::String(email)) if !is_valid_email(email) => {
            errors.push("[body.email] Invalid email address".to_owned());
        }
        None | Some(Value::String(_)) => {}
        value => {
            aborted = true;
            errors.push(format!(
                "[body.email] Invalid input: expected string, received {}",
                received(value)
            ));
        }
    }
    if let Some(unknown) = unknown_keys(&body, &["message", "signature", "email"]) {
        errors.push(unknown);
        aborted = true;
    }
    if !anonymous
        && !aborted
        && body
            .get("email")
            .and_then(Value::as_str)
            .is_none_or(str::is_empty)
    {
        errors.push(
            "[body.email] Email is required when the anonymous plugin option is disabled."
                .to_owned(),
        );
    }
    if !errors.is_empty() {
        return Err(error(&(errors.join("; "))));
    }
    Ok(VerifyBody {
        message: body
            .get("message")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_owned(),
        signature: body
            .get("signature")
            .and_then(Value::as_str)
            .unwrap_or_default()
            .to_owned(),
        email: body.get("email").and_then(Value::as_str).map(str::to_owned),
    })
}