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).expect("test setup");
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).expect("test setup");
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).expect("test setup");
213 let origins = vec!["https://example.com".to_string()];
214
215 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
216 .expect("test setup");
217 }
218
219 #[test]
220 fn validate_rejects_wrong_type() {
221 let challenge = vec![0u8; 32];
222 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
223 let raw = make_raw("webauthn.get", &b64, "https://example.com");
224 let parsed = parse_client_data(&raw).expect("test setup");
225 let origins = vec!["https://example.com".to_string()];
226
227 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
228 .expect_err("expected error");
229 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
230 }
231
232 #[test]
233 fn validate_rejects_challenge_mismatch() {
234 let challenge = vec![0xAAu8; 32];
235 let wrong = vec![0xBBu8; 32];
236 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
237 let raw = make_raw("webauthn.create", &b64, "https://example.com");
238 let parsed = parse_client_data(&raw).expect("test setup");
239 let origins = vec!["https://example.com".to_string()];
240
241 let err = validate_client_data(&parsed, "webauthn.create", &wrong, &origins, false)
242 .expect_err("expected error");
243 assert!(matches!(err, WebAuthnError::ChallengeMismatch));
244 }
245
246 #[test]
247 fn validate_rejects_origin_mismatch() {
248 let challenge = vec![0u8; 32];
249 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
250 let raw = make_raw("webauthn.create", &b64, "https://evil.com");
251 let parsed = parse_client_data(&raw).expect("test setup");
252 let origins = vec!["https://example.com".to_string()];
253
254 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
255 .expect_err("expected error");
256 assert!(matches!(
257 err,
258 WebAuthnError::OriginMismatch { expected, got }
259 if expected == "https://example.com" && got == "https://evil.com"
260 ));
261 }
262
263 #[test]
264 fn rejects_empty_bytes() {
265 let err = parse_client_data(&[]).expect_err("expected error");
266 assert!(matches!(err, WebAuthnError::InvalidClientData(ref m) if m.contains("empty")));
267 }
268
269 #[test]
270 fn rejects_utf8_but_not_json() {
271 let err = parse_client_data(b"hello world, not json").expect_err("expected error");
272 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
273 }
274
275 #[test]
276 fn rejects_json_missing_type_field() {
277 let challenge = URL_SAFE_NO_PAD.encode([0u8; 32]);
278 let raw = format!(r#"{{"challenge":"{challenge}","origin":"https://x.com"}}"#).into_bytes();
279 let err = parse_client_data(&raw).expect_err("expected error");
280 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
281 }
282
283 #[test]
284 fn rejects_json_missing_challenge_field() {
285 let raw = br#"{"type":"webauthn.create","origin":"https://x.com"}"#.to_vec();
286 let err = parse_client_data(&raw).expect_err("expected error");
287 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
288 }
289
290 #[test]
291 fn rejects_json_missing_origin_field() {
292 let challenge = URL_SAFE_NO_PAD.encode([0u8; 32]);
293 let raw = format!(r#"{{"type":"webauthn.create","challenge":"{challenge}"}}"#).into_bytes();
294 let err = parse_client_data(&raw).expect_err("expected error");
295 assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
296 }
297
298 #[test]
299 fn rejects_challenge_with_invalid_base64() {
300 let raw =
301 br#"{"type":"webauthn.create","challenge":"!!!invalid!!!","origin":"https://x.com"}"#
302 .to_vec();
303 let err = parse_client_data(&raw).expect_err("expected error");
304 assert!(matches!(err, WebAuthnError::Base64DecodeError(_)));
305 }
306
307 #[test]
308 fn accepts_challenge_with_base64_padding() {
309 let challenge_bytes = vec![0xFEu8, 0xED, 0xBE];
313 let b64_no_pad = URL_SAFE_NO_PAD.encode(&challenge_bytes);
314 let b64_padded = URL_SAFE.encode(&challenge_bytes);
315
316 let raw_no_pad = make_raw("webauthn.create", &b64_no_pad, "https://x.com");
317 let raw_padded = make_raw("webauthn.create", &b64_padded, "https://x.com");
318
319 let parsed_no_pad = parse_client_data(&raw_no_pad).expect("test setup");
320 assert_eq!(parsed_no_pad.challenge_bytes, challenge_bytes);
321
322 if let Ok(parsed_padded) = parse_client_data(&raw_padded) {
323 assert_eq!(parsed_padded.challenge_bytes, challenge_bytes);
324 }
325 }
326
327 #[test]
328 fn validate_rejects_empty_type_field() {
329 let challenge = vec![0u8; 32];
330 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
331 let raw = make_raw("", &b64, "https://example.com");
332 let parsed = parse_client_data(&raw).expect("test setup");
333 let origins = vec!["https://example.com".to_string()];
334 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
335 .expect_err("expected error");
336 assert!(matches!(err, WebAuthnError::InvalidClientData(ref m) if m.contains("empty")));
337 }
338
339 #[test]
340 fn validate_rejects_origin_with_trailing_slash() {
341 let challenge = vec![0u8; 32];
343 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
344 let raw = make_raw("webauthn.create", &b64, "https://example.com/");
345 let parsed = parse_client_data(&raw).expect("test setup");
346 let origins = vec!["https://example.com".to_string()];
347 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
348 .expect_err("expected error");
349 assert!(matches!(err, WebAuthnError::OriginMismatch { .. }));
350 }
351
352 #[test]
353 fn cross_origin_true_accepted_when_reject_disabled() {
354 let challenge = vec![0u8; 32];
356 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
357 let raw = format!(
358 r#"{{"type":"webauthn.create","challenge":"{b64}","origin":"https://example.com","crossOrigin":true}}"#
359 )
360 .into_bytes();
361 let parsed = parse_client_data(&raw).expect("test setup");
362 assert!(parsed.cross_origin);
363 let origins = vec!["https://example.com".to_string()];
364 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
365 .expect("crossOrigin:true must not fail when reject_cross_origin is false");
366 }
367
368 #[test]
369 fn cross_origin_true_rejected_when_reject_enabled() {
370 let challenge = vec![0u8; 32];
372 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
373 let raw = format!(
374 r#"{{"type":"webauthn.create","challenge":"{b64}","origin":"https://example.com","crossOrigin":true}}"#
375 )
376 .into_bytes();
377 let parsed = parse_client_data(&raw).expect("test setup");
378 let origins = vec!["https://example.com".to_string()];
379 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, true)
380 .expect_err("expected error");
381 assert!(matches!(err, WebAuthnError::CrossOriginNotAllowed));
382 }
383
384 #[test]
385 fn cross_origin_false_accepted_when_reject_enabled() {
386 let challenge = vec![0u8; 32];
389 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
390 let raw = format!(
391 r#"{{"type":"webauthn.create","challenge":"{b64}","origin":"https://example.com","crossOrigin":false}}"#
392 )
393 .into_bytes();
394 let parsed = parse_client_data(&raw).expect("test setup");
395 assert!(!parsed.cross_origin);
396 let origins = vec!["https://example.com".to_string()];
397 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, true)
398 .expect("test setup");
399 }
400
401 #[test]
402 fn cross_origin_absent_accepted_when_reject_enabled() {
403 let challenge = vec![0u8; 32];
405 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
406 let raw = make_raw("webauthn.create", &b64, "https://example.com");
407 let parsed = parse_client_data(&raw).expect("test setup");
408 assert!(!parsed.cross_origin);
409 let origins = vec!["https://example.com".to_string()];
410 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, true)
411 .expect("test setup");
412 }
413
414 #[test]
415 fn validate_accepts_origin_in_multi_origin_list() {
416 let challenge = vec![0u8; 32];
417 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
418 let raw = make_raw("webauthn.create", &b64, "https://second.com");
419 let parsed = parse_client_data(&raw).expect("test setup");
420 let origins = vec![
421 "https://first.com".to_string(),
422 "https://second.com".to_string(),
423 ];
424 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
425 .expect("test setup");
426 }
427
428 #[test]
429 fn validate_rejects_origin_not_in_list() {
430 let challenge = vec![0u8; 32];
431 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
432 let raw = make_raw("webauthn.create", &b64, "https://evil.com");
433 let parsed = parse_client_data(&raw).expect("test setup");
434 let origins = vec![
435 "https://first.com".to_string(),
436 "https://second.com".to_string(),
437 ];
438 let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
439 .expect_err("expected error");
440 assert!(matches!(
441 err,
442 WebAuthnError::OriginMismatch { got, .. } if got == "https://evil.com"
443 ));
444 }
445
446 #[test]
447 fn validate_accepts_single_origin_list() {
448 let challenge = vec![0u8; 32];
449 let b64 = URL_SAFE_NO_PAD.encode(&challenge);
450 let raw = make_raw("webauthn.create", &b64, "https://example.com");
451 let parsed = parse_client_data(&raw).expect("test setup");
452 let origins = vec!["https://example.com".to_string()];
453 validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
454 .expect("test setup");
455 }
456}