alibi_plugins/siwe/
validation.rs1use 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#[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 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 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 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}