Skip to main content

webauthn/
client_data.rs

1//! Parsing and validation of `clientDataJSON`.
2//!
3//! `clientDataJSON` is a JSON object produced by the browser that binds a
4//! WebAuthn ceremony to a specific type, challenge, and origin. The relying
5//! party must verify all three fields before accepting any ceremony.
6//!
7//! This module separates parsing from validation so that error messages can
8//! name the exact failing field (type mismatch vs challenge mismatch vs origin
9//! mismatch) rather than a generic "invalid client data".
10
11use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
12use serde::Deserialize;
13
14use crate::error::{Result, WebAuthnError};
15
16// ─── Raw JSON structure ───────────────────────────────────────────────────────
17
18/// The JSON fields we extract from `clientDataJSON`.
19///
20/// The spec allows additional fields (e.g. `tokenBinding`, extensions). We
21/// accept and ignore them; serde's default behaviour handles this correctly.
22#[derive(Debug, Deserialize)]
23struct RawClientData {
24    #[serde(rename = "type")]
25    type_: String,
26    challenge: String, // base64url-encoded in the JSON
27    origin: String,
28    #[serde(rename = "crossOrigin")]
29    cross_origin: Option<bool>,
30}
31
32// ─── Public types ─────────────────────────────────────────────────────────────
33
34/// Decoded and structured `clientDataJSON` ready for validation.
35#[derive(Debug)]
36pub struct ParsedClientData {
37    /// The ceremony type — `"webauthn.create"` or `"webauthn.get"`.
38    pub type_: String,
39
40    /// The raw challenge bytes (base64url-decoded from the JSON `challenge` field).
41    pub challenge_bytes: Vec<u8>,
42
43    /// The origin the client reports (e.g. `"https://example.com"`).
44    pub origin: String,
45
46    /// Whether the browser flagged this as a cross-origin credential use.
47    ///
48    /// `true` when `crossOrigin` is present and set to `true` in the JSON;
49    /// `false` when absent or explicitly `false`.
50    pub cross_origin: bool,
51
52    /// A copy of the original raw JSON bytes.
53    ///
54    /// Kept because `clientDataHash = SHA-256(clientDataJSON)` must be computed
55    /// over the exact bytes as received — not a re-serialised version.
56    pub raw_json: Vec<u8>,
57}
58
59// ─── Public functions ─────────────────────────────────────────────────────────
60
61/// Decode and parse raw `clientDataJSON` bytes.
62///
63/// `raw` must be the UTF-8 JSON bytes — **not** base64url encoded. The caller
64/// is responsible for base64url decoding the wire value before calling this.
65///
66/// Does **not** validate type, challenge, or origin — call [`validate_client_data`]
67/// for that, so each check can produce a precise error.
68///
69/// # Errors
70/// - [`WebAuthnError::InvalidClientData`] — JSON parse failure or missing fields.
71/// - [`WebAuthnError::Base64DecodeError`] — challenge field is not valid base64url.
72pub 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
95/// Validate the parsed client data against the expected ceremony parameters.
96///
97/// Checks (in order): ceremony type, challenge bytes, origin, cross-origin.
98/// Returns the first mismatch found so the caller receives the most specific
99/// error available.
100///
101/// # Arguments
102/// * `parsed`               — Output of [`parse_client_data`].
103/// * `expected_type`        — `"webauthn.create"` or `"webauthn.get"`.
104/// * `expected_challenge`   — The raw challenge bytes the relying party issued.
105/// * `allowed_origins`      — All origins this RP accepts. The client-supplied
106///   origin must equal at least one entry exactly.
107/// * `reject_cross_origin`  — When `true`, reject any response with
108///   `crossOrigin: true` in the client data (§7.1 step 10 / §7.2 step 12).
109///
110/// # Errors
111/// - [`WebAuthnError::InvalidClientData`]   — type field does not match.
112/// - [`WebAuthnError::ChallengeMismatch`]   — challenge bytes do not match.
113/// - [`WebAuthnError::OriginMismatch`]      — origin is not in the allowed list.
114/// - [`WebAuthnError::CrossOriginNotAllowed`] — cross-origin flag set and RP rejects it.
115pub 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    // Verify the ceremony type. An empty type string is always wrong.
123    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    // Verify the challenge matches byte-for-byte.
137    if parsed.challenge_bytes != expected_challenge {
138        return Err(WebAuthnError::ChallengeMismatch);
139    }
140
141    // Verify the origin is in the allowed list.
142    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    // §7.1 step 10 / §7.2 step 12 — reject cross-origin use when the RP
150    // has opted in to strict same-origin enforcement.
151    if reject_cross_origin && parsed.cross_origin {
152        return Err(WebAuthnError::CrossOriginNotAllowed);
153    }
154
155    Ok(())
156}
157
158// ─── Tests ────────────────────────────────────────────────────────────────────
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    // URL_SAFE (with padding) — used to test padded base64url acceptance.
164    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        // Some implementations include base64url padding ("=="); both forms must
309        // decode to the same bytes. URL_SAFE_NO_PAD is our canonical encoder, so
310        // no-pad form must always work. The padded form is best-effort.
311        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        // Per spec, origins must match exactly — trailing slash is a different origin.
341        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        // Default behaviour (reject_cross_origin=false): crossOrigin:true is allowed.
354        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        // §7.1 step 10: RP may reject crossOrigin:true.
370        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        // crossOrigin:false (or absent) must always be accepted, even when
386        // reject_cross_origin is true.
387        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        // Missing crossOrigin key defaults to false — must not be rejected.
402        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}