1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
12use serde::Deserialize;
13
14use crate::error::{Result, WebAuthnError};
15
16#[derive(Debug, Deserialize)]
23struct RawClientData {
24 #[serde(rename = "type")]
25 type_: String,
26 challenge: String, origin: String,
28 #[serde(rename = "crossOrigin")]
29 cross_origin: Option<bool>,
30}
31
32#[derive(Debug)]
36pub struct ParsedClientData {
37 pub type_: String,
39
40 pub challenge_bytes: Vec<u8>,
42
43 pub origin: String,
45
46 pub cross_origin: bool,
51
52 pub raw_json: Vec<u8>,
57}
58
59pub fn parse_client_data(raw: &[u8]) -> Result<ParsedClientData> {
73 if raw.is_empty() {
74 return Err(WebAuthnError::InvalidClientData(
75 "empty client data".to_string(),
76 ));
77 }
78
79 let rcd: RawClientData = serde_json::from_slice(raw)
80 .map_err(|e| WebAuthnError::InvalidClientData(format!("JSON parse failed: {e}")))?;
81
82 let challenge_bytes = URL_SAFE_NO_PAD
83 .decode(&rcd.challenge)
84 .map_err(|e| WebAuthnError::Base64DecodeError(format!("challenge field: {e}")))?;
85
86 Ok(ParsedClientData {
87 type_: rcd.type_,
88 challenge_bytes,
89 origin: rcd.origin,
90 cross_origin: rcd.cross_origin.unwrap_or(false),
91 raw_json: raw.to_vec(),
92 })
93}
94
95pub fn validate_client_data(
116 parsed: &ParsedClientData,
117 expected_type: &str,
118 expected_challenge: &[u8],
119 allowed_origins: &[String],
120 reject_cross_origin: bool,
121) -> Result<()> {
122 if parsed.type_.is_empty() {
124 return Err(WebAuthnError::InvalidClientData(
125 "type field is empty".to_string(),
126 ));
127 }
128
129 if parsed.type_ != expected_type {
130 return Err(WebAuthnError::InvalidClientData(format!(
131 "expected type \"{expected_type}\", got \"{}\"",
132 parsed.type_
133 )));
134 }
135
136 if parsed.challenge_bytes != expected_challenge {
138 return Err(WebAuthnError::ChallengeMismatch);
139 }
140
141 if !allowed_origins.iter().any(|o| o == &parsed.origin) {
143 return Err(WebAuthnError::OriginMismatch {
144 expected: allowed_origins.join(", "),
145 got: parsed.origin.clone(),
146 });
147 }
148
149 if reject_cross_origin && parsed.cross_origin {
152 return Err(WebAuthnError::CrossOriginNotAllowed);
153 }
154
155 Ok(())
156}
157
158#[cfg(test)]
161mod tests {
162 use super::*;
163 use base64::engine::general_purpose::URL_SAFE;
165
166 fn make_raw(type_: &str, challenge_b64: &str, origin: &str) -> Vec<u8> {
167 format!(r#"{{"type":"{type_}","challenge":"{challenge_b64}","origin":"{origin}"}}"#)
168 .into_bytes()
169 }
170
171 #[test]
172 fn parses_valid_create() {
173 let challenge_bytes = vec![1u8; 32];
174 let challenge_b64 = URL_SAFE_NO_PAD.encode(&challenge_bytes);
175 let raw = make_raw("webauthn.create", &challenge_b64, "https://example.com");
176
177 let parsed = parse_client_data(&raw).unwrap();
178 assert_eq!(parsed.type_, "webauthn.create");
179 assert_eq!(parsed.challenge_bytes, challenge_bytes);
180 assert_eq!(parsed.origin, "https://example.com");
181 assert_eq!(parsed.raw_json, raw);
182 }
183
184 #[test]
185 fn parses_valid_get() {
186 let challenge_bytes = vec![2u8; 32];
187 let challenge_b64 = URL_SAFE_NO_PAD.encode(&challenge_bytes);
188 let raw = make_raw("webauthn.get", &challenge_b64, "https://example.com");
189
190 let parsed = parse_client_data(&raw).unwrap();
191 assert_eq!(parsed.type_, "webauthn.get");
192 }
193
194 #[test]
195 fn rejects_invalid_json() {
196 let result = parse_client_data(b"not json at all");
197 assert!(matches!(result, Err(WebAuthnError::InvalidClientData(_))));
198 }
199
200 #[test]
201 fn rejects_bad_challenge_encoding() {
202 let raw = br#"{"type":"webauthn.create","challenge":"!!!","origin":"https://x.com"}"#;
203 let result = parse_client_data(raw);
204 assert!(matches!(result, Err(WebAuthnError::Base64DecodeError(_))));
205 }
206
207 #[test]
208 fn validate_accepts_correct_fields() {
209 let challenge = vec![0xABu8; 32];
210 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
211 let raw = make_raw("webauthn.create", &b64, "https://example.com");
212 let parsed = parse_client_data(&raw).unwrap();
213 let origins = vec!["https://example.com".to_string()];
214
215 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false).unwrap();
216 }
217
218 #[test]
219 fn validate_rejects_wrong_type() {
220 let challenge = vec![0u8; 32];
221 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
222 let raw = make_raw("webauthn.get", &b64, "https://example.com");
223 let parsed = parse_client_data(&raw).unwrap();
224 let origins = vec!["https://example.com".to_string()];
225
226 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
227 .unwrap_err();
228 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
229 }
230
231 #[test]
232 fn validate_rejects_challenge_mismatch() {
233 let challenge = vec![0xAAu8; 32];
234 let wrong = vec![0xBBu8; 32];
235 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
236 let raw = make_raw("webauthn.create", &b64, "https://example.com");
237 let parsed = parse_client_data(&raw).unwrap();
238 let origins = vec!["https://example.com".to_string()];
239
240 let err =
241 validate_client_data(&parsed, "webauthn.create", &wrong, &origins, false).unwrap_err();
242 assert!(matches!(err, WebAuthnError::ChallengeMismatch));
243 }
244
245 #[test]
246 fn validate_rejects_origin_mismatch() {
247 let challenge = vec![0u8; 32];
248 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
249 let raw = make_raw("webauthn.create", &b64, "https://evil.com");
250 let parsed = parse_client_data(&raw).unwrap();
251 let origins = vec!["https://example.com".to_string()];
252
253 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
254 .unwrap_err();
255 assert!(matches!(
256 err,
257 WebAuthnError::OriginMismatch { expected, got }
258 if expected == "https://example.com" && got == "https://evil.com"
259 ));
260 }
261
262 #[test]
263 fn rejects_empty_bytes() {
264 let err = parse_client_data(&[]).unwrap_err();
265 assert!(matches!(err, WebAuthnError::InvalidClientData(ref m) if m.contains("empty")));
266 }
267
268 #[test]
269 fn rejects_utf8_but_not_json() {
270 let err = parse_client_data(b"hello world, not json").unwrap_err();
271 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
272 }
273
274 #[test]
275 fn rejects_json_missing_type_field() {
276 let challenge = URL_SAFE_NO_PAD.encode([0u8; 32]);
277 let raw = format!(r#"{{"challenge":"{challenge}","origin":"https://x.com"}}"#).into_bytes();
278 let err = parse_client_data(&raw).unwrap_err();
279 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
280 }
281
282 #[test]
283 fn rejects_json_missing_challenge_field() {
284 let raw = br#"{"type":"webauthn.create","origin":"https://x.com"}"#.to_vec();
285 let err = parse_client_data(&raw).unwrap_err();
286 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
287 }
288
289 #[test]
290 fn rejects_json_missing_origin_field() {
291 let challenge = URL_SAFE_NO_PAD.encode([0u8; 32]);
292 let raw = format!(r#"{{"type":"webauthn.create","challenge":"{challenge}"}}"#).into_bytes();
293 let err = parse_client_data(&raw).unwrap_err();
294 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
295 }
296
297 #[test]
298 fn rejects_challenge_with_invalid_base64() {
299 let raw =
300 br#"{"type":"webauthn.create","challenge":"!!!invalid!!!","origin":"https://x.com"}"#
301 .to_vec();
302 let err = parse_client_data(&raw).unwrap_err();
303 assert!(matches!(err, WebAuthnError::Base64DecodeError(_)));
304 }
305
306 #[test]
307 fn accepts_challenge_with_base64_padding() {
308 let challenge_bytes = vec![0xFEu8, 0xED, 0xBE];
312 let b64_no_pad = URL_SAFE_NO_PAD.encode(&challenge_bytes);
313 let b64_padded = URL_SAFE.encode(&challenge_bytes);
314
315 let raw_no_pad = make_raw("webauthn.create", &b64_no_pad, "https://x.com");
316 let raw_padded = make_raw("webauthn.create", &b64_padded, "https://x.com");
317
318 let parsed_no_pad = parse_client_data(&raw_no_pad).unwrap();
319 assert_eq!(parsed_no_pad.challenge_bytes, challenge_bytes);
320
321 if let Ok(parsed_padded) = parse_client_data(&raw_padded) {
322 assert_eq!(parsed_padded.challenge_bytes, challenge_bytes);
323 }
324 }
325
326 #[test]
327 fn validate_rejects_empty_type_field() {
328 let challenge = vec![0u8; 32];
329 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
330 let raw = make_raw("", &b64, "https://example.com");
331 let parsed = parse_client_data(&raw).unwrap();
332 let origins = vec!["https://example.com".to_string()];
333 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
334 .unwrap_err();
335 assert!(matches!(err, WebAuthnError::InvalidClientData(ref m) if m.contains("empty")));
336 }
337
338 #[test]
339 fn validate_rejects_origin_with_trailing_slash() {
340 let challenge = vec![0u8; 32];
342 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
343 let raw = make_raw("webauthn.create", &b64, "https://example.com/");
344 let parsed = parse_client_data(&raw).unwrap();
345 let origins = vec!["https://example.com".to_string()];
346 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
347 .unwrap_err();
348 assert!(matches!(err, WebAuthnError::OriginMismatch { .. }));
349 }
350
351 #[test]
352 fn cross_origin_true_accepted_when_reject_disabled() {
353 let challenge = vec![0u8; 32];
355 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
356 let raw = format!(
357 r#"{{"type":"webauthn.create","challenge":"{b64}","origin":"https://example.com","crossOrigin":true}}"#
358 )
359 .into_bytes();
360 let parsed = parse_client_data(&raw).unwrap();
361 assert!(parsed.cross_origin);
362 let origins = vec!["https://example.com".to_string()];
363 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
364 .expect("crossOrigin:true must not fail when reject_cross_origin is false");
365 }
366
367 #[test]
368 fn cross_origin_true_rejected_when_reject_enabled() {
369 let challenge = vec![0u8; 32];
371 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
372 let raw = format!(
373 r#"{{"type":"webauthn.create","challenge":"{b64}","origin":"https://example.com","crossOrigin":true}}"#
374 )
375 .into_bytes();
376 let parsed = parse_client_data(&raw).unwrap();
377 let origins = vec!["https://example.com".to_string()];
378 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, true)
379 .unwrap_err();
380 assert!(matches!(err, WebAuthnError::CrossOriginNotAllowed));
381 }
382
383 #[test]
384 fn cross_origin_false_accepted_when_reject_enabled() {
385 let challenge = vec![0u8; 32];
388 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
389 let raw = format!(
390 r#"{{"type":"webauthn.create","challenge":"{b64}","origin":"https://example.com","crossOrigin":false}}"#
391 )
392 .into_bytes();
393 let parsed = parse_client_data(&raw).unwrap();
394 assert!(!parsed.cross_origin);
395 let origins = vec!["https://example.com".to_string()];
396 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, true).unwrap();
397 }
398
399 #[test]
400 fn cross_origin_absent_accepted_when_reject_enabled() {
401 let challenge = vec![0u8; 32];
403 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
404 let raw = make_raw("webauthn.create", &b64, "https://example.com");
405 let parsed = parse_client_data(&raw).unwrap();
406 assert!(!parsed.cross_origin);
407 let origins = vec!["https://example.com".to_string()];
408 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, true).unwrap();
409 }
410
411 #[test]
412 fn validate_accepts_origin_in_multi_origin_list() {
413 let challenge = vec![0u8; 32];
414 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
415 let raw = make_raw("webauthn.create", &b64, "https://second.com");
416 let parsed = parse_client_data(&raw).unwrap();
417 let origins = vec![
418 "https://first.com".to_string(),
419 "https://second.com".to_string(),
420 ];
421 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false).unwrap();
422 }
423
424 #[test]
425 fn validate_rejects_origin_not_in_list() {
426 let challenge = vec![0u8; 32];
427 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
428 let raw = make_raw("webauthn.create", &b64, "https://evil.com");
429 let parsed = parse_client_data(&raw).unwrap();
430 let origins = vec![
431 "https://first.com".to_string(),
432 "https://second.com".to_string(),
433 ];
434 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
435 .unwrap_err();
436 assert!(matches!(
437 err,
438 WebAuthnError::OriginMismatch { got, .. } if got == "https://evil.com"
439 ));
440 }
441
442 #[test]
443 fn validate_accepts_single_origin_list() {
444 let challenge = vec![0u8; 32];
445 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
446 let raw = make_raw("webauthn.create", &b64, "https://example.com");
447 let parsed = parse_client_data(&raw).unwrap();
448 let origins = vec!["https://example.com".to_string()];
449 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false).unwrap();
450 }
451}