use anyhow::{anyhow, Result};
use serde::Deserialize;
pub const TYPE_CREATE: &str = "webauthn.create";
pub const TYPE_GET: &str = "webauthn.get";
#[derive(Debug, Clone, Deserialize)]
pub struct ClientData {
#[serde(rename = "type")]
pub typ: String,
pub challenge: String,
pub origin: String,
#[serde(default, rename = "crossOrigin", alias = "cross_origin")]
pub cross_origin: bool,
}
impl ClientData {
pub fn parse(bytes: &[u8]) -> Result<Self> {
serde_json::from_slice(bytes).map_err(|e| anyhow!("invalid clientDataJSON: {e}"))
}
pub fn verify_expectations(
&self,
expected_type: &str,
expected_challenge_b64: &str,
allowed_origins: &[String],
) -> Result<()> {
use super::challenge::base64url_decode;
if self.typ != expected_type {
return Err(anyhow!(
"clientData type mismatch: expected {expected_type}, got {}",
self.typ
));
}
let issued = base64url_decode(expected_challenge_b64)
.map_err(|_| anyhow!("issued challenge is not valid base64url"))?;
let presented = base64url_decode(&self.challenge)
.map_err(|_| anyhow!("presented challenge is not valid base64url"))?;
if !crate::utils::constant_time_eq(&issued, &presented) {
return Err(anyhow!("clientData challenge mismatch"));
}
if self.cross_origin {
return Err(anyhow!("cross_origin ceremonies are not accepted"));
}
let origin = self.origin.to_ascii_lowercase();
if !allowed_origins
.iter()
.any(|o| o.to_ascii_lowercase() == origin)
{
return Err(anyhow!("origin {:?} not in allowlist", self.origin));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(challenge: &str, origin: &str) -> Vec<u8> {
format!(
r#"{{"type":"webauthn.create","challenge":"{challenge}","origin":"{origin}","crossOrigin":false}}"#
)
.into_bytes()
}
#[test]
fn parses_minimal_payload() {
let cd = ClientData::parse(&sample("abc", "https://a.example")).unwrap();
assert_eq!(cd.typ, "webauthn.create");
assert_eq!(cd.challenge, "abc");
assert_eq!(cd.origin, "https://a.example");
assert!(!cd.cross_origin);
}
#[test]
fn cross_origin_camel_case_is_deserialized() {
let bytes = br#"{"type":"webauthn.get","challenge":"abc","origin":"https://a.example","crossOrigin":true}"#;
let cd = ClientData::parse(bytes).unwrap();
assert!(cd.cross_origin);
let err = cd
.verify_expectations(TYPE_GET, "abc", &["https://a.example".into()])
.unwrap_err();
assert!(format!("{err:#}").contains("cross_origin"));
}
#[test]
fn rejects_type_mismatch() {
let cd = ClientData::parse(&sample("abc", "https://a.example")).unwrap();
assert!(cd
.verify_expectations(TYPE_GET, "abc", &["https://a.example".into()])
.is_err());
}
#[test]
fn rejects_wrong_challenge() {
let cd = ClientData::parse(&sample("abc", "https://a.example")).unwrap();
assert!(cd
.verify_expectations(TYPE_CREATE, "xyz", &["https://a.example".into()])
.is_err());
}
#[test]
fn rejects_unlisted_origin() {
let cd = ClientData::parse(&sample("abc", "https://evil.example")).unwrap();
assert!(cd
.verify_expectations(TYPE_CREATE, "abc", &["https://a.example".into()])
.is_err());
}
#[test]
fn accepts_matching_expectations() {
let cd = ClientData::parse(&sample("abc", "https://a.example")).unwrap();
assert!(cd
.verify_expectations(
TYPE_CREATE,
"abc",
&["https://other.example".into(), "https://a.example".into()]
)
.is_ok());
}
}