Skip to main content

alibi_plugins/siwe/
validation.rs

1use crate::authentication_helpers::{is_valid_email, json_type};
2use alibi_core::error::codes;
3use alibi_core::utils::json::JsValue;
4use alibi_core::{AuthRequest, AuthResponse, types::ParsedRequestBody};
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value, json};
7
8/// Signed SIWE arguments shared by HTTP and server callers.
9#[serde_with::skip_serializing_none]
10#[derive(Debug, Serialize, Deserialize)]
11pub struct SiweVerificationInput {
12    pub message: String,
13    pub signature: String,
14    pub email: Option<String>,
15}
16
17pub(super) type VerifyBody = SiweVerificationInput;
18
19fn error(message: &str) -> AuthResponse {
20    AuthResponse::json(
21        400,
22        &json!({"message":message,"code":codes::VALIDATION_ERROR}),
23    )
24    .unwrap_or_else(|_| AuthResponse::text(400, "Invalid request body"))
25}
26
27const fn received(value: Option<&Value>) -> &'static str {
28    match value {
29        None => "undefined",
30        Some(Value::Null) => "null",
31        Some(Value::Array(_)) => "array",
32        Some(Value::Object(_)) => "object",
33        Some(Value::Bool(_)) => "boolean",
34        Some(Value::String(_)) => "string",
35        Some(Value::Number(_)) => "number",
36    }
37}
38
39fn body(request: &AuthRequest, optional: bool) -> Result<Map<String, Value>, AuthResponse> {
40    if request.body.is_some() {
41        let content_type = request
42            .header("content-type")
43            .map(String::as_str)
44            .unwrap_or_default();
45        let normalized = content_type.to_ascii_lowercase();
46        let media_type = normalized.split(';').next().unwrap_or_default().trim();
47        // Better Call checks the explicit endpoint allowlist before decoding any
48        // body. Its matching rule permits a containing media type as well.
49        if !media_type.contains("application/json") {
50            let message = if normalized.is_empty() {
51                "Content-Type is required. Allowed types: application/json".to_owned()
52            } else {
53                format!(
54                    "Content-Type \"{content_type}\" is not allowed. Allowed types: application/json"
55                )
56            };
57            return Err(AuthResponse::json(
58                415,
59                &json!({"message":message,"code":codes::UNSUPPORTED_MEDIA_TYPE}),
60            )
61            .unwrap_or_else(|_| AuthResponse::text(415, "Unsupported media type")));
62        }
63    }
64    // Use the transport's decoded value rather than interpreting every admitted
65    // media type as JSON bytes. ArrayBuffer exposes no schema fields. Other
66    // opaque transport bodies are rejected without synthesizing runtime objects.
67    if let Some(decoded) = request.extensions().get::<ParsedRequestBody>()
68        && let ParsedRequestBody::Opaque(kind) = &*decoded
69    {
70        return if *kind == "ArrayBuffer" {
71            Ok(Map::new())
72        } else {
73            Err(error(&format!(
74                "[body] Invalid input: expected object, received {kind}"
75            )))
76        };
77    }
78    let parsed = request
79        .body
80        .as_ref()
81        .map(|_| request.body_as_json::<Value>())
82        .transpose()
83        .map_err(|_error| {
84            AuthResponse::json(
85                400,
86                &json!({"message":"Invalid JSON in request body","code":codes::BAD_REQUEST}),
87            )
88            .unwrap_or_else(|_| AuthResponse::text(400, "Invalid JSON in request body"))
89        })?;
90    match parsed {
91        None if optional => Ok(Map::new()),
92        Some(Value::Object(body)) => Ok(body),
93        value => Err(error(
94            &(format!(
95                "[body] Invalid input: expected object, received {}",
96                received(value.as_ref())
97            )),
98        )),
99    }
100}
101
102fn unknown_keys(body: &Map<String, Value>, known: &[&str]) -> Option<String> {
103    unknown_field_keys(body.keys().map(String::as_str), known)
104}
105
106pub(super) fn unknown_field_keys<'a>(
107    keys: impl Iterator<Item = &'a str>,
108    known: &[&str],
109) -> Option<String> {
110    let mut keys: Vec<&str> = keys.filter(|key| !known.contains(key)).collect();
111    // JavaScript enumerates canonical numeric indexes first. The stable sort
112    // retains insertion order for ordinary property names.
113    keys.sort_by_key(|key| {
114        key.parse::<u32>()
115            .ok()
116            .filter(|index| *index != u32::MAX && index.to_string() == *key)
117            .map_or((1, 0), |index| (0, index))
118    });
119    if keys.is_empty() {
120        return None;
121    }
122    let formatted = keys
123        .iter()
124        .map(|key| format!("\"{key}\""))
125        .collect::<Vec<_>>()
126        .join(", ");
127    Some(format!(
128        "[body] Unrecognized key{}: {formatted}",
129        if keys.len() == 1 { "" } else { "s" }
130    ))
131}
132
133pub(super) fn nonce_body(request: &AuthRequest) -> Result<(), AuthResponse> {
134    let body = body(request, true)?;
135    if let Some(message) = unknown_keys(&body, &[]) {
136        return Err(error(&(message)));
137    }
138    Ok(())
139}
140
141pub(super) fn verify_body(
142    request: &AuthRequest,
143    anonymous: bool,
144) -> Result<VerifyBody, AuthResponse> {
145    let body = JsValue::from(Value::Object(body(request, false)?));
146    verify_value(Some(&body), anonymous)
147}
148
149pub(super) fn verify_value(
150    value: Option<&JsValue>,
151    anonymous: bool,
152) -> Result<VerifyBody, AuthResponse> {
153    let body = value.and_then(JsValue::as_object).ok_or_else(|| {
154        error(&format!(
155            "[body] Invalid input: expected object, received {}",
156            json_type(value)
157        ))
158    })?;
159    let mut errors = Vec::new();
160    let mut aborted = false;
161    for field in ["message", "signature"] {
162        match body.get(field) {
163            Some(JsValue::String(value)) if value.is_empty() => errors.push(format!(
164                "[body.{field}] Too small: expected string to have >=1 characters"
165            )),
166            Some(JsValue::String(_)) => {}
167            value => {
168                aborted = true;
169                errors.push(format!(
170                    "[body.{field}] Invalid input: expected string, received {}",
171                    json_type(value)
172                ));
173            }
174        }
175    }
176    match body.get("email") {
177        Some(JsValue::String(email)) if !is_valid_email(email) => {
178            errors.push("[body.email] Invalid email address".to_owned());
179        }
180        None | Some(JsValue::String(_)) => {}
181        value => {
182            aborted = true;
183            errors.push(format!(
184                "[body.email] Invalid input: expected string, received {}",
185                json_type(value)
186            ));
187        }
188    }
189    if let Some(unknown) = unknown_field_keys(
190        body.keys().map(String::as_str),
191        &["message", "signature", "email"],
192    ) {
193        errors.push(unknown);
194        aborted = true;
195    }
196    if !anonymous
197        && !aborted
198        && body
199            .get("email")
200            .and_then(JsValue::as_str)
201            .is_none_or(str::is_empty)
202    {
203        errors.push(
204            "[body.email] Email is required when the anonymous plugin option is disabled."
205                .to_owned(),
206        );
207    }
208    if !errors.is_empty() {
209        return Err(error(&(errors.join("; "))));
210    }
211    Ok(VerifyBody {
212        message: body
213            .get("message")
214            .and_then(JsValue::as_str)
215            .unwrap_or_default()
216            .to_owned(),
217        signature: body
218            .get("signature")
219            .and_then(JsValue::as_str)
220            .unwrap_or_default()
221            .to_owned(),
222        email: body
223            .get("email")
224            .and_then(JsValue::as_str)
225            .map(str::to_owned),
226    })
227}