use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::Deserialize;
use crate::error::{Result, WebAuthnError};
#[derive(Debug, Deserialize)]
struct RawClientData {
#[serde(rename = "type")]
type_: String,
challenge: String, origin: String,
#[serde(rename = "crossOrigin")]
cross_origin: Option<bool>,
}
#[derive(Debug)]
pub struct ParsedClientData {
pub type_: String,
pub challenge_bytes: Vec<u8>,
pub origin: String,
pub cross_origin: bool,
pub raw_json: Vec<u8>,
}
pub fn parse_client_data(raw: &[u8]) -> Result<ParsedClientData> {
if raw.is_empty() {
return Err(WebAuthnError::InvalidClientData(
"empty client data".to_string(),
));
}
let rcd: RawClientData = serde_json::from_slice(raw)
.map_err(|e| WebAuthnError::InvalidClientData(format!("JSON parse failed: {e}")))?;
let challenge_bytes = URL_SAFE_NO_PAD
.decode(&rcd.challenge)
.map_err(|e| WebAuthnError::Base64DecodeError(format!("challenge field: {e}")))?;
Ok(ParsedClientData {
type_: rcd.type_,
challenge_bytes,
origin: rcd.origin,
cross_origin: rcd.cross_origin.unwrap_or(false),
raw_json: raw.to_vec(),
})
}
pub fn validate_client_data(
parsed: &ParsedClientData,
expected_type: &str,
expected_challenge: &[u8],
allowed_origins: &[String],
reject_cross_origin: bool,
) -> Result<()> {
if parsed.type_.is_empty() {
return Err(WebAuthnError::InvalidClientData(
"type field is empty".to_string(),
));
}
if parsed.type_ != expected_type {
return Err(WebAuthnError::InvalidClientData(format!(
"expected type \"{expected_type}\", got \"{}\"",
parsed.type_
)));
}
if parsed.challenge_bytes != expected_challenge {
return Err(WebAuthnError::ChallengeMismatch);
}
if !allowed_origins.iter().any(|o| o == &parsed.origin) {
return Err(WebAuthnError::OriginMismatch {
expected: allowed_origins.join(", "),
got: parsed.origin.clone(),
});
}
if reject_cross_origin && parsed.cross_origin {
return Err(WebAuthnError::CrossOriginNotAllowed);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use base64::engine::general_purpose::URL_SAFE;
fn make_raw(type_: &str, challenge_b64: &str, origin: &str) -> Vec<u8> {
format!(r#"{{"type":"{type_}","challenge":"{challenge_b64}","origin":"{origin}"}}"#)
.into_bytes()
}
#[test]
fn parses_valid_create() {
let challenge_bytes = vec![1u8; 32];
let challenge_b64 = URL_SAFE_NO_PAD.encode(&challenge_bytes);
let raw = make_raw("webauthn.create", &challenge_b64, "https://example.com");
let parsed = parse_client_data(&raw).expect("test setup");
assert_eq!(parsed.type_, "webauthn.create");
assert_eq!(parsed.challenge_bytes, challenge_bytes);
assert_eq!(parsed.origin, "https://example.com");
assert_eq!(parsed.raw_json, raw);
}
#[test]
fn parses_valid_get() {
let challenge_bytes = vec![2u8; 32];
let challenge_b64 = URL_SAFE_NO_PAD.encode(&challenge_bytes);
let raw = make_raw("webauthn.get", &challenge_b64, "https://example.com");
let parsed = parse_client_data(&raw).expect("test setup");
assert_eq!(parsed.type_, "webauthn.get");
}
#[test]
fn rejects_invalid_json() {
let result = parse_client_data(b"not json at all");
assert!(matches!(result, Err(WebAuthnError::InvalidClientData(_))));
}
#[test]
fn rejects_bad_challenge_encoding() {
let raw = br#"{"type":"webauthn.create","challenge":"!!!","origin":"https://x.com"}"#;
let result = parse_client_data(raw);
assert!(matches!(result, Err(WebAuthnError::Base64DecodeError(_))));
}
#[test]
fn validate_accepts_correct_fields() {
let challenge = vec![0xABu8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("webauthn.create", &b64, "https://example.com");
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec!["https://example.com".to_string()];
validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
.expect("test setup");
}
#[test]
fn validate_rejects_wrong_type() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("webauthn.get", &b64, "https://example.com");
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec!["https://example.com".to_string()];
let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
.expect_err("expected error");
assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
}
#[test]
fn validate_rejects_challenge_mismatch() {
let challenge = vec![0xAAu8; 32];
let wrong = vec![0xBBu8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("webauthn.create", &b64, "https://example.com");
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec!["https://example.com".to_string()];
let err = validate_client_data(&parsed, "webauthn.create", &wrong, &origins, false)
.expect_err("expected error");
assert!(matches!(err, WebAuthnError::ChallengeMismatch));
}
#[test]
fn validate_rejects_origin_mismatch() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("webauthn.create", &b64, "https://evil.com");
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec!["https://example.com".to_string()];
let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
.expect_err("expected error");
assert!(matches!(
err,
WebAuthnError::OriginMismatch { expected, got }
if expected == "https://example.com" && got == "https://evil.com"
));
}
#[test]
fn rejects_empty_bytes() {
let err = parse_client_data(&[]).expect_err("expected error");
assert!(matches!(err, WebAuthnError::InvalidClientData(ref m) if m.contains("empty")));
}
#[test]
fn rejects_utf8_but_not_json() {
let err = parse_client_data(b"hello world, not json").expect_err("expected error");
assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
}
#[test]
fn rejects_json_missing_type_field() {
let challenge = URL_SAFE_NO_PAD.encode([0u8; 32]);
let raw = format!(r#"{{"challenge":"{challenge}","origin":"https://x.com"}}"#).into_bytes();
let err = parse_client_data(&raw).expect_err("expected error");
assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
}
#[test]
fn rejects_json_missing_challenge_field() {
let raw = br#"{"type":"webauthn.create","origin":"https://x.com"}"#.to_vec();
let err = parse_client_data(&raw).expect_err("expected error");
assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
}
#[test]
fn rejects_json_missing_origin_field() {
let challenge = URL_SAFE_NO_PAD.encode([0u8; 32]);
let raw = format!(r#"{{"type":"webauthn.create","challenge":"{challenge}"}}"#).into_bytes();
let err = parse_client_data(&raw).expect_err("expected error");
assert!(matches!(err, WebAuthnError::InvalidClientData(_)));
}
#[test]
fn rejects_challenge_with_invalid_base64() {
let raw =
br#"{"type":"webauthn.create","challenge":"!!!invalid!!!","origin":"https://x.com"}"#
.to_vec();
let err = parse_client_data(&raw).expect_err("expected error");
assert!(matches!(err, WebAuthnError::Base64DecodeError(_)));
}
#[test]
fn accepts_challenge_with_base64_padding() {
let challenge_bytes = vec![0xFEu8, 0xED, 0xBE];
let b64_no_pad = URL_SAFE_NO_PAD.encode(&challenge_bytes);
let b64_padded = URL_SAFE.encode(&challenge_bytes);
let raw_no_pad = make_raw("webauthn.create", &b64_no_pad, "https://x.com");
let raw_padded = make_raw("webauthn.create", &b64_padded, "https://x.com");
let parsed_no_pad = parse_client_data(&raw_no_pad).expect("test setup");
assert_eq!(parsed_no_pad.challenge_bytes, challenge_bytes);
if let Ok(parsed_padded) = parse_client_data(&raw_padded) {
assert_eq!(parsed_padded.challenge_bytes, challenge_bytes);
}
}
#[test]
fn validate_rejects_empty_type_field() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("", &b64, "https://example.com");
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec!["https://example.com".to_string()];
let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
.expect_err("expected error");
assert!(matches!(err, WebAuthnError::InvalidClientData(ref m) if m.contains("empty")));
}
#[test]
fn validate_rejects_origin_with_trailing_slash() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("webauthn.create", &b64, "https://example.com/");
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec!["https://example.com".to_string()];
let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
.expect_err("expected error");
assert!(matches!(err, WebAuthnError::OriginMismatch { .. }));
}
#[test]
fn cross_origin_true_accepted_when_reject_disabled() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = format!(
r#"{{"type":"webauthn.create","challenge":"{b64}","origin":"https://example.com","crossOrigin":true}}"#
)
.into_bytes();
let parsed = parse_client_data(&raw).expect("test setup");
assert!(parsed.cross_origin);
let origins = vec!["https://example.com".to_string()];
validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
.expect("crossOrigin:true must not fail when reject_cross_origin is false");
}
#[test]
fn cross_origin_true_rejected_when_reject_enabled() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = format!(
r#"{{"type":"webauthn.create","challenge":"{b64}","origin":"https://example.com","crossOrigin":true}}"#
)
.into_bytes();
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec!["https://example.com".to_string()];
let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, true)
.expect_err("expected error");
assert!(matches!(err, WebAuthnError::CrossOriginNotAllowed));
}
#[test]
fn cross_origin_false_accepted_when_reject_enabled() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = format!(
r#"{{"type":"webauthn.create","challenge":"{b64}","origin":"https://example.com","crossOrigin":false}}"#
)
.into_bytes();
let parsed = parse_client_data(&raw).expect("test setup");
assert!(!parsed.cross_origin);
let origins = vec!["https://example.com".to_string()];
validate_client_data(&parsed, "webauthn.create", &challenge, &origins, true)
.expect("test setup");
}
#[test]
fn cross_origin_absent_accepted_when_reject_enabled() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("webauthn.create", &b64, "https://example.com");
let parsed = parse_client_data(&raw).expect("test setup");
assert!(!parsed.cross_origin);
let origins = vec!["https://example.com".to_string()];
validate_client_data(&parsed, "webauthn.create", &challenge, &origins, true)
.expect("test setup");
}
#[test]
fn validate_accepts_origin_in_multi_origin_list() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("webauthn.create", &b64, "https://second.com");
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec![
"https://first.com".to_string(),
"https://second.com".to_string(),
];
validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
.expect("test setup");
}
#[test]
fn validate_rejects_origin_not_in_list() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("webauthn.create", &b64, "https://evil.com");
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec![
"https://first.com".to_string(),
"https://second.com".to_string(),
];
let err = validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
.expect_err("expected error");
assert!(matches!(
err,
WebAuthnError::OriginMismatch { got, .. } if got == "https://evil.com"
));
}
#[test]
fn validate_accepts_single_origin_list() {
let challenge = vec![0u8; 32];
let b64 = URL_SAFE_NO_PAD.encode(&challenge);
let raw = make_raw("webauthn.create", &b64, "https://example.com");
let parsed = parse_client_data(&raw).expect("test setup");
let origins = vec!["https://example.com".to_string()];
validate_client_data(&parsed, "webauthn.create", &challenge, &origins, false)
.expect("test setup");
}
}