Skip to main content

alibi_plugins/siwe/
validation.rs

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