alibi-plugins 0.8.0

Built-in authentication plugins and API metadata for Alibi
Documentation
use crate::authentication_helpers::{is_valid_email, json_type};
use alibi_core::error::codes;
use alibi_core::utils::json::JsValue;
use alibi_core::{AuthRequest, AuthResponse, types::ParsedRequestBody};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};

/// Signed SIWE arguments shared by HTTP and server callers.
#[serde_with::skip_serializing_none]
#[derive(Debug, Serialize, Deserialize)]
pub struct SiweVerificationInput {
    pub message: String,
    pub signature: String,
    pub email: Option<String>,
}

pub(super) type VerifyBody = SiweVerificationInput;

fn error(message: &str) -> AuthResponse {
    AuthResponse::json(
        400,
        &json!({"message":message,"code":codes::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":codes::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":codes::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> {
    unknown_field_keys(body.keys().map(String::as_str), known)
}

pub(super) fn unknown_field_keys<'a>(
    keys: impl Iterator<Item = &'a str>,
    known: &[&str],
) -> Option<String> {
    let mut keys: Vec<&str> = keys.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 = JsValue::from(Value::Object(body(request, false)?));
    verify_value(Some(&body), anonymous)
}

pub(super) fn verify_value(
    value: Option<&JsValue>,
    anonymous: bool,
) -> Result<VerifyBody, AuthResponse> {
    let body = value.and_then(JsValue::as_object).ok_or_else(|| {
        error(&format!(
            "[body] Invalid input: expected object, received {}",
            json_type(value)
        ))
    })?;
    let mut errors = Vec::new();
    let mut aborted = false;
    for field in ["message", "signature"] {
        match body.get(field) {
            Some(JsValue::String(value)) if value.is_empty() => errors.push(format!(
                "[body.{field}] Too small: expected string to have >=1 characters"
            )),
            Some(JsValue::String(_)) => {}
            value => {
                aborted = true;
                errors.push(format!(
                    "[body.{field}] Invalid input: expected string, received {}",
                    json_type(value)
                ));
            }
        }
    }
    match body.get("email") {
        Some(JsValue::String(email)) if !is_valid_email(email) => {
            errors.push("[body.email] Invalid email address".to_owned());
        }
        None | Some(JsValue::String(_)) => {}
        value => {
            aborted = true;
            errors.push(format!(
                "[body.email] Invalid input: expected string, received {}",
                json_type(value)
            ));
        }
    }
    if let Some(unknown) = unknown_field_keys(
        body.keys().map(String::as_str),
        &["message", "signature", "email"],
    ) {
        errors.push(unknown);
        aborted = true;
    }
    if !anonymous
        && !aborted
        && body
            .get("email")
            .and_then(JsValue::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(JsValue::as_str)
            .unwrap_or_default()
            .to_owned(),
        signature: body
            .get("signature")
            .and_then(JsValue::as_str)
            .unwrap_or_default()
            .to_owned(),
        email: body
            .get("email")
            .and_then(JsValue::as_str)
            .map(str::to_owned),
    })
}