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).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        // Some implementations include base64url padding ("=="); both forms must
310        // decode to the same bytes. URL_SAFE_NO_PAD is our canonical encoder, so
311        // no-pad form must always work. The padded form is best-effort.
312        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        // Per spec, origins must match exactly — trailing slash is a different origin.
342        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        // Default behaviour (reject_cross_origin=false): crossOrigin:true is allowed.
355        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        // §7.1 step 10: RP may reject crossOrigin:true.
371        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        // crossOrigin:false (or absent) must always be accepted, even when
387        // reject_cross_origin is true.
388        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        // Missing crossOrigin key defaults to false — must not be rejected.
404        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}