diff --git a/Cargo.lock b/Cargo.lock
index 71a43831..330c0390 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -200,6 +200,12 @@ dependencies = [
"critical-section",
]
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
[[package]]
name = "autocfg"
version = "1.5.0"
@@ -1014,6 +1020,15 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
[[package]]
name = "env_filter"
version = "1.0.1"
@@ -1147,6 +1162,12 @@ dependencies = [
"synstructure 0.13.2",
]
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
[[package]]
name = "foldhash"
version = "0.1.5"
@@ -1290,8 +1311,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi",
+ "wasm-bindgen",
]
[[package]]
@@ -1301,9 +1324,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
+ "wasm-bindgen",
]
[[package]]
@@ -1357,6 +1382,25 @@ dependencies = [
"subtle",
]
+[[package]]
+name = "h2"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "half"
version = "2.7.1"
@@ -1492,12 +1536,95 @@ dependencies = [
"itoa",
]
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+[[package]]
+name = "hyper"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "rustls-native-certs",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "icu_collections"
version = "2.2.0"
@@ -1650,6 +1777,12 @@ dependencies = [
"loom",
]
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -1832,6 +1965,7 @@ dependencies = [
"publicsuffix",
"qrcode",
"rand 0.8.6",
+ "reqwest",
"rustls",
"serde",
"serde-indexed 0.2.0",
@@ -1956,6 +2090,12 @@ dependencies = [
"tracing-subscriber",
]
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
[[package]]
name = "macaddr"
version = "1.0.1"
@@ -1983,6 +2123,12 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -2528,6 +2674,61 @@ dependencies = [
"image",
]
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash 2.1.2",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash 2.1.2",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.52.0",
+]
+
[[package]]
name = "quote"
version = "1.0.45"
@@ -2652,6 +2853,50 @@ version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
[[package]]
name = "rfc6979"
version = "0.4.0"
@@ -2751,6 +2996,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
+ "web-time",
"zeroize",
]
@@ -2771,6 +3017,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
[[package]]
name = "salty"
version = "0.3.0"
@@ -2974,6 +3226,18 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
[[package]]
name = "serdect"
version = "0.2.0"
@@ -3196,6 +3460,15 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
[[package]]
name = "synstructure"
version = "0.12.6"
@@ -3372,6 +3645,21 @@ dependencies = [
"zerovec",
]
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
[[package]]
name = "tokio"
version = "1.52.3"
@@ -3481,6 +3769,51 @@ dependencies = [
"winnow",
]
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
+dependencies = [
+ "bitflags 2.11.1",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
[[package]]
name = "tracing"
version = "0.1.44"
@@ -3646,6 +3979,12 @@ dependencies = [
"trussed-hkdf",
]
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
[[package]]
name = "tungstenite"
version = "0.26.2"
@@ -3769,6 +4108,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -3806,6 +4154,16 @@ dependencies = [
"wasm-bindgen-shared",
]
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.71"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.121"
@@ -3860,6 +4218,19 @@ dependencies = [
"wasmparser",
]
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "wasmparser"
version = "0.244.0"
@@ -3872,6 +4243,26 @@ dependencies = [
"semver",
]
+[[package]]
+name = "web-sys"
+version = "0.3.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
[[package]]
name = "winapi"
version = "0.3.9"
diff --git a/README.md b/README.md
index 5bc43093..a55caad3 100644
--- a/README.md
+++ b/README.md
@@ -68,16 +68,18 @@ $ git submodule update --init
The basic ceremony examples (register + authenticate) cover all transports. The
WebAuthn examples consume and emit JSON per the [WebAuthn IDL][webauthn].
-| Transport | FIDO U2F | WebAuthn (FIDO2) |
-| --------------------- | ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
-| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --example webauthn_hid` |
-| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | `cargo run --example webauthn_ble` |
-| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`
`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc --example webauthn_nfc`
`cargo run --features nfc-backend-libnfc --example webauthn_nfc` |
-| **Hybrid (caBLE v2 + CTAP 2.3)** | — | `cargo run --example webauthn_cable` |
-| **Hybrid (caBLE v2)** | — | `cargo run --example webauthn_cable_wss` |
+| Transport | FIDO U2F | WebAuthn (FIDO2) [^ro] |
+| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **USB (HID)** | `cargo run --example u2f_hid` | `cargo run --features related-origins-client --example webauthn_hid` |
+| **Bluetooth (BLE)** | `cargo run --example u2f_ble` | `cargo run --features related-origins-client --example webauthn_ble` |
+| **NFC** [^nfc] | `cargo run --features nfc-backend-pcsc --example u2f_nfc`
`cargo run --features nfc-backend-libnfc --example u2f_nfc` | `cargo run --features nfc-backend-pcsc,related-origins-client --example webauthn_nfc`
`cargo run --features nfc-backend-libnfc,related-origins-client --example webauthn_nfc` |
+| **Hybrid (caBLE v2 + CTAP 2.3)** | — | `cargo run --features related-origins-client --example webauthn_cable` |
+| **Hybrid (caBLE v2)** | — | `cargo run --features related-origins-client --example webauthn_cable_wss` |
[^nfc]: `nfc-backend-pcsc` is pure userspace and recommended on most systems. `nfc-backend-libnfc` requires the `libnfc` system library. Both can be enabled together; the first FIDO device found by either backend is used.
+[^ro]: The WebAuthn ceremony examples wire up the bundled reqwest-backed [related-origins](https://www.w3.org/TR/webauthn-3/#sctn-related-origins) client, which lives behind the optional `related-origins-client` feature. Consumers that already ship their own HTTP stack can implement `RelatedOriginsHttpClient` directly and omit the feature.
+
Additional HID-only examples cover specific FIDO2 features and authenticator management:
```
diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml
index dd71749d..89285be5 100644
--- a/libwebauthn/Cargo.toml
+++ b/libwebauthn/Cargo.toml
@@ -26,6 +26,10 @@ nfc-backend-libnfc = [
# external crates (e.g. libwebauthn-tests) can plug in a virtual HID transport
# for end-to-end tests.
virt = []
+# Provides the reqwest-backed default RelatedOriginsHttpClient. Off by default so
+# the core crate stays HTTP-client-free; consumers that want the default impl
+# opt in, others bring their own client.
+related-origins-client = ["dep:reqwest"]
[dependencies]
base64-url = "3.0.0"
@@ -85,6 +89,12 @@ apdu = { version = "0.4.0", optional = true }
pcsc = { version = "2.9.0", optional = true }
nfc1 = { version = "=0.6.0", optional = true, default-features = false }
nfc1-sys = { version = "0.3.9", optional = true, default-features = false }
+reqwest = { version = "0.12", default-features = false, features = [
+ "rustls-tls-native-roots",
+ "http2",
+ "stream",
+ "charset",
+], optional = true }
[dev-dependencies]
tracing-subscriber = { version = "0.3.3", features = ["env-filter"] }
@@ -116,23 +126,27 @@ required-features = ["nfc"]
[[example]]
name = "webauthn_hid"
path = "examples/ceremony/webauthn_hid.rs"
+required-features = ["related-origins-client"]
[[example]]
name = "webauthn_ble"
path = "examples/ceremony/webauthn_ble.rs"
+required-features = ["related-origins-client"]
[[example]]
name = "webauthn_nfc"
path = "examples/ceremony/webauthn_nfc.rs"
-required-features = ["nfc"]
+required-features = ["nfc", "related-origins-client"]
[[example]]
name = "webauthn_cable"
path = "examples/ceremony/webauthn_cable.rs"
+required-features = ["related-origins-client"]
[[example]]
name = "webauthn_cable_wss"
path = "examples/ceremony/webauthn_cable_wss.rs"
+required-features = ["related-origins-client"]
[[example]]
name = "webauthn_extensions_hid"
diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs
index f46fbc01..916531e4 100644
--- a/libwebauthn/examples/ceremony/webauthn_ble.rs
+++ b/libwebauthn/examples/ceremony/webauthn_ble.rs
@@ -2,7 +2,7 @@ use std::error::Error;
use libwebauthn::ops::webauthn::{
DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin,
- WebAuthnIDL as _, WebAuthnIDLResponse as _,
+ ReqwestRelatedOriginsClient, WebAuthnIDL as _, WebAuthnIDLResponse as _,
};
use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor;
use libwebauthn::transport::ble::list_devices;
@@ -52,8 +52,10 @@ pub async fn main() -> Result<(), Box> {
"attestation": "none"
}
"#;
+ let related_origins = ReqwestRelatedOriginsClient::new()?;
let make_credentials_request: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &psl, request_json)
+ MakeCredentialRequest::from_json(&request_origin, &psl, &related_origins, request_json)
+ .await
.expect("Failed to parse request JSON");
println!(
"WebAuthn MakeCredential request: {:?}",
@@ -97,7 +99,8 @@ pub async fn main() -> Result<(), Box> {
"#
);
let get_assertion: GetAssertionRequest =
- GetAssertionRequest::from_json(&request_origin, &psl, &request_json)
+ GetAssertionRequest::from_json(&request_origin, &psl, &related_origins, &request_json)
+ .await
.expect("Failed to parse request JSON");
println!("WebAuthn GetAssertion request: {:?}", get_assertion);
diff --git a/libwebauthn/examples/ceremony/webauthn_cable.rs b/libwebauthn/examples/ceremony/webauthn_cable.rs
index 779406b4..7f7ae5bd 100644
--- a/libwebauthn/examples/ceremony/webauthn_cable.rs
+++ b/libwebauthn/examples/ceremony/webauthn_cable.rs
@@ -11,8 +11,8 @@ use qrcode::render::unicode;
use qrcode::QrCode;
use libwebauthn::ops::webauthn::{
- DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, RequestOrigin, WebAuthnIDL as _,
- WebAuthnIDLResponse as _,
+ DatFilePublicSuffixList, JsonFormat, MakeCredentialRequest, RequestOrigin,
+ ReqwestRelatedOriginsClient, WebAuthnIDL as _, WebAuthnIDLResponse as _,
};
use libwebauthn::transport::{Channel as _, Device};
use libwebauthn::webauthn::WebAuthn;
@@ -58,6 +58,7 @@ pub async fn main() -> Result<(), Box> {
let psl = DatFilePublicSuffixList::from_system_file().expect(
"PSL not available; install the publicsuffix-list package or pass an explicit path",
);
+ let related_origins = ReqwestRelatedOriginsClient::new()?;
let mut device: CableQrCodeDevice = CableQrCodeDevice::new_transient(
QrCodeOperationHint::MakeCredential,
@@ -79,8 +80,14 @@ pub async fn main() -> Result<(), Box> {
let state_recv = channel.get_ux_update_receiver();
tokio::spawn(common::handle_cable_updates(state_recv));
- let request = MakeCredentialRequest::from_json(&request_origin, &psl, MAKE_CREDENTIAL_REQUEST)
- .expect("Failed to parse request JSON");
+ let request = MakeCredentialRequest::from_json(
+ &request_origin,
+ &psl,
+ &related_origins,
+ MAKE_CREDENTIAL_REQUEST,
+ )
+ .await
+ .expect("Failed to parse request JSON");
let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
let response_json = response
diff --git a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs
index fc3c40c2..a35f5faf 100644
--- a/libwebauthn/examples/ceremony/webauthn_cable_wss.rs
+++ b/libwebauthn/examples/ceremony/webauthn_cable_wss.rs
@@ -14,8 +14,8 @@ use qrcode::QrCode;
use tokio::time::sleep;
use libwebauthn::ops::webauthn::{
- GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList,
- WebAuthnIDL as _, WebAuthnIDLResponse as _,
+ GetAssertionRequest, JsonFormat, MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin,
+ SystemPublicSuffixList, WebAuthnIDL as _, WebAuthnIDLResponse as _,
};
use libwebauthn::transport::cable::channel::CableChannel;
use libwebauthn::transport::{Channel as _, Device};
@@ -95,9 +95,14 @@ pub async fn main() -> Result<(), Box> {
let state_recv = channel.get_ux_update_receiver();
tokio::spawn(common::handle_cable_updates(state_recv));
- let request =
- MakeCredentialRequest::from_json(&request_origin, &psl, MAKE_CREDENTIAL_REQUEST)
- .expect("Failed to parse request JSON");
+ let request = MakeCredentialRequest::from_json(
+ &request_origin,
+ &psl,
+ &NoRelatedOriginsClient,
+ MAKE_CREDENTIAL_REQUEST,
+ )
+ .await
+ .expect("Failed to parse request JSON");
let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
let response_json = response
@@ -155,8 +160,14 @@ async fn run_get_assertion(
let state_recv = channel.get_ux_update_receiver();
tokio::spawn(common::handle_cable_updates(state_recv));
- let request = GetAssertionRequest::from_json(request_origin, psl, GET_ASSERTION_REQUEST)
- .expect("Failed to parse request JSON");
+ let request = GetAssertionRequest::from_json(
+ request_origin,
+ psl,
+ &NoRelatedOriginsClient,
+ GET_ASSERTION_REQUEST,
+ )
+ .await
+ .expect("Failed to parse request JSON");
let response = retry_user_errors!(channel.webauthn_get_assertion(&request)).unwrap();
for assertion in &response.assertions {
let assertion_json = assertion
diff --git a/libwebauthn/examples/ceremony/webauthn_hid.rs b/libwebauthn/examples/ceremony/webauthn_hid.rs
index aa03a04b..cbddd2ab 100644
--- a/libwebauthn/examples/ceremony/webauthn_hid.rs
+++ b/libwebauthn/examples/ceremony/webauthn_hid.rs
@@ -2,8 +2,9 @@ use std::error::Error;
use std::time::Duration;
use libwebauthn::ops::webauthn::{
- GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList,
- WebAuthnIDL as _, WebAuthnIDLResponse as _,
+ GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin,
+ ReqwestRelatedOriginsClient, SystemPublicSuffixList, WebAuthnIDL as _,
+ WebAuthnIDLResponse as _,
};
use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor;
use libwebauthn::transport::hid::list_devices;
@@ -32,6 +33,7 @@ pub async fn main() -> Result<(), Box> {
let psl = SystemPublicSuffixList::auto().expect(
"PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path",
);
+ let related_origins = ReqwestRelatedOriginsClient::new()?;
let request_json = r#"
{
"rp": {
@@ -57,7 +59,8 @@ pub async fn main() -> Result<(), Box> {
}
"#;
let make_credentials_request: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &psl, request_json)
+ MakeCredentialRequest::from_json(&request_origin, &psl, &related_origins, request_json)
+ .await
.expect("Failed to parse request JSON");
println!(
"WebAuthn MakeCredential request: {:?}",
@@ -101,7 +104,8 @@ pub async fn main() -> Result<(), Box> {
"#
);
let get_assertion: GetAssertionRequest =
- GetAssertionRequest::from_json(&request_origin, &psl, &request_json)
+ GetAssertionRequest::from_json(&request_origin, &psl, &related_origins, &request_json)
+ .await
.expect("Failed to parse request JSON");
println!("WebAuthn GetAssertion request: {:?}", get_assertion);
diff --git a/libwebauthn/examples/ceremony/webauthn_nfc.rs b/libwebauthn/examples/ceremony/webauthn_nfc.rs
index 6c58fdb9..cdfc0595 100644
--- a/libwebauthn/examples/ceremony/webauthn_nfc.rs
+++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs
@@ -1,8 +1,9 @@
use std::error::Error;
use libwebauthn::ops::webauthn::{
- GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList,
- WebAuthnIDL as _, WebAuthnIDLResponse as _,
+ GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin,
+ ReqwestRelatedOriginsClient, SystemPublicSuffixList, WebAuthnIDL as _,
+ WebAuthnIDLResponse as _,
};
use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available};
use libwebauthn::transport::{Channel as _, Device};
@@ -30,9 +31,11 @@ pub async fn main() -> Result<(), Box> {
let psl = SystemPublicSuffixList::auto().expect(
"PSL not available; install the publicsuffix-list (or publicsuffix-list-dafsa) package, or pass an explicit path",
);
+ let related_origins = ReqwestRelatedOriginsClient::new()?;
let make_credentials_request = MakeCredentialRequest::from_json(
&request_origin,
&psl,
+ &related_origins,
r#"
{
"rp": {
@@ -58,6 +61,7 @@ pub async fn main() -> Result<(), Box> {
}
"#,
)
+ .await
.expect("Failed to parse request JSON");
println!(
"WebAuthn MakeCredential request: {:?}",
@@ -77,6 +81,7 @@ pub async fn main() -> Result<(), Box> {
let get_assertion = GetAssertionRequest::from_json(
&request_origin,
&psl,
+ &related_origins,
r#"
{
"challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu",
@@ -86,6 +91,7 @@ pub async fn main() -> Result<(), Box> {
}
"#,
)
+ .await
.expect("Failed to parse request JSON");
println!("WebAuthn GetAssertion request: {:?}", get_assertion);
diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs
index 7e45b51d..dc79f645 100644
--- a/libwebauthn/src/ops/webauthn/get_assertion.rs
+++ b/libwebauthn/src/ops/webauthn/get_assertion.rs
@@ -1,5 +1,6 @@
use std::{collections::HashMap, time::Duration};
+use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing::{debug, error, trace};
@@ -22,6 +23,7 @@ use crate::{
Base64UrlString, FromIdlModel, JsonError,
},
psl::PublicSuffixList,
+ related_origins::{validate_related_origins, RelatedOriginsHttpClient},
Operation, WebAuthnIDL,
},
pin::PinUvAuthProtocol,
@@ -166,12 +168,14 @@ impl WebAuthnIDL for GetAssertionRequest {
sequence hints = [];
AuthenticationExtensionsClientInputsJSON extensions;
}; */
+#[async_trait]
impl FromIdlModel
for GetAssertionRequest
{
- fn from_idl_model(
+ async fn from_idl_model(
request_origin: &RequestOrigin,
psl: &dyn PublicSuffixList,
+ http: &dyn RelatedOriginsHttpClient,
inner: PublicKeyCredentialRequestOptionsJSON,
) -> Result {
let effective_rp_id = request_origin.origin.host.as_str();
@@ -179,12 +183,16 @@ impl FromIdlModel> for GetAssertionRequest {
mod tests {
use std::time::Duration;
+ use async_trait::async_trait;
use serde_bytes::ByteBuf;
use crate::ops::webauthn::psl::MockPublicSuffixList;
- use crate::ops::webauthn::{GetAssertionRequest, RequestOrigin};
+ use crate::ops::webauthn::related_origins::{
+ RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse,
+ };
+ use crate::ops::webauthn::{GetAssertionRequest, NoRelatedOriginsClient, RequestOrigin};
use crate::proto::ctap2::Ctap2PublicKeyCredentialType;
use super::*;
+ /// Test-only HTTP client backed by a fixed response. `panicking` proves the
+ /// suffix-check short-circuit by failing the test if the fetch is invoked.
+ struct MockHttpClient {
+ response: Option>,
+ }
+
+ impl MockHttpClient {
+ fn ok_body(body: &str) -> Self {
+ Self {
+ response: Some(Ok(WellKnownResponse {
+ content_type: Some("application/json".into()),
+ body: body.as_bytes().to_vec(),
+ })),
+ }
+ }
+
+ fn err(e: WellKnownFetchError) -> Self {
+ Self {
+ response: Some(Err(e)),
+ }
+ }
+
+ fn panicking() -> Self {
+ Self { response: None }
+ }
+ }
+
+ #[async_trait]
+ impl RelatedOriginsHttpClient for MockHttpClient {
+ async fn fetch_well_known(
+ &self,
+ _: &RelyingPartyId,
+ ) -> Result {
+ match &self.response {
+ Some(r) => r.clone(),
+ None => panic!("fetch_well_known should not be called"),
+ }
+ }
+ }
+
pub const REQUEST_BASE_JSON: &str = r#"
{
"challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu",
@@ -705,49 +757,66 @@ mod tests {
serde_json::to_string(&v).unwrap()
}
- #[test]
- fn test_request_from_json_base() {
+ #[tokio::test]
+ async fn test_request_from_json_base() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req: GetAssertionRequest = GetAssertionRequest::from_json(
&request_origin,
&MockPublicSuffixList,
+ &NoRelatedOriginsClient,
REQUEST_BASE_JSON,
)
+ .await
.unwrap();
assert_eq!(req, request_base());
}
- #[test]
- fn test_request_from_json_ignore_missing_rp_id() {
+ #[tokio::test]
+ async fn test_request_from_json_ignore_missing_rp_id() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_rm(REQUEST_BASE_JSON, "rpId");
- let req: GetAssertionRequest =
- GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: GetAssertionRequest = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(req, request_base());
}
- #[test]
- fn test_request_from_json_invalid_rp_id() {
+ #[tokio::test]
+ async fn test_request_from_json_invalid_rp_id() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.org.""#);
- let result =
- GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
Err(GetAssertionRequestParsingError::InvalidRelyingPartyId(_))
));
}
- #[test]
- fn test_request_from_json_mismatching_rp_id() {
+ #[tokio::test]
+ async fn test_request_from_json_mismatching_rp_id() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""other.example.org""#);
- let result =
- GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
@@ -757,26 +826,37 @@ mod tests {
));
}
- #[test]
- fn test_request_from_json_rp_id_is_parent_registrable_suffix() {
+ #[tokio::test]
+ async fn test_request_from_json_rp_id_is_parent_registrable_suffix() {
// origin = login.example.org, rp.id = example.org -> accepted.
let request_origin: RequestOrigin = "https://login.example.org".parse().unwrap();
let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.org""#);
- let req = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(req.relying_party_id, "example.org");
assert_eq!(req.origin, "https://login.example.org");
}
- #[test]
- fn test_request_from_json_rp_id_is_etld_rejected() {
+ #[tokio::test]
+ async fn test_request_from_json_rp_id_is_etld_rejected() {
// origin = example.co.uk, rp.id = co.uk (a public suffix) -> rejected.
let request_origin: RequestOrigin = "https://example.co.uk".parse().unwrap();
let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""co.uk""#);
- let result =
- GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
@@ -786,14 +866,100 @@ mod tests {
));
}
- #[test]
- fn test_request_from_json_ignore_missing_allow_credentials() {
+ // `.de` substituted with `.org` (MockPublicSuffixList lacks `.de`); pattern
+ // (different eTLD between caller origin and rp.id) is identical.
+
+ #[tokio::test]
+ async fn related_origins_match_resolves_mismatch() {
+ let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap();
+ let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#);
+ let http = MockHttpClient::ok_body(r#"{"origins":["https://app.example.org"]}"#);
+
+ let req = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &http,
+ &req_json,
+ )
+ .await
+ .unwrap();
+ assert_eq!(req.relying_party_id, "example.com");
+ }
+
+ #[tokio::test]
+ async fn related_origins_no_match_keeps_mismatch_error() {
+ let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap();
+ let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#);
+ let http = MockHttpClient::ok_body(r#"{"origins":["https://other.org"]}"#);
+
+ let result = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &http,
+ &req_json,
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
+ _,
+ _
+ ))
+ ));
+ }
+
+ #[tokio::test]
+ async fn related_origins_fetch_error_keeps_mismatch_error() {
+ let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap();
+ let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#);
+ let http = MockHttpClient::err(WellKnownFetchError::Transport("simulated".into()));
+
+ let result = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &http,
+ &req_json,
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
+ _,
+ _
+ ))
+ ));
+ }
+
+ #[tokio::test]
+ async fn related_origins_not_consulted_when_suffix_matches() {
+ let request_origin: RequestOrigin = "https://login.example.com".parse().unwrap();
+ let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#);
+ let http = MockHttpClient::panicking();
+
+ let req = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &http,
+ &req_json,
+ )
+ .await
+ .unwrap();
+ assert_eq!(req.relying_party_id, "example.com");
+ }
+
+ #[tokio::test]
+ async fn test_request_from_json_ignore_missing_allow_credentials() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_rm(REQUEST_BASE_JSON, "allowCredentials");
- let req: GetAssertionRequest =
- GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: GetAssertionRequest = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(
req,
GetAssertionRequest {
@@ -803,36 +969,46 @@ mod tests {
);
}
- #[test]
- fn test_request_from_json_default_timeout() {
+ #[tokio::test]
+ async fn test_request_from_json_default_timeout() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_rm(REQUEST_BASE_JSON, "timeout");
- let req: GetAssertionRequest =
- GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: GetAssertionRequest = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(req.timeout, DEFAULT_TIMEOUT);
}
- #[test]
- fn test_request_from_json_empty_extensions() {
+ #[tokio::test]
+ async fn test_request_from_json_empty_extensions() {
// Test that "extensions": {} results in Some(default) not None
// This is important for strict portals that distinguish between
// no extensions key vs empty extensions object
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{}"#);
- let req: GetAssertionRequest =
- GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: GetAssertionRequest = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(
req.extensions,
Some(GetAssertionRequestExtensions::default())
);
}
- #[test]
- fn test_request_from_json_appid_extension() {
+ #[tokio::test]
+ async fn test_request_from_json_appid_extension() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
@@ -840,9 +1016,14 @@ mod tests {
r#"{"appid":"https://www.example.org/u2f/origins.json"}"#,
);
- let req: GetAssertionRequest =
- GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: GetAssertionRequest = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
let ext = req.extensions.expect("extensions should be present");
assert_eq!(
ext.appid.as_deref(),
@@ -850,8 +1031,8 @@ mod tests {
);
}
- #[test]
- fn test_request_from_json_appid_extension_invalid_non_https() {
+ #[tokio::test]
+ async fn test_request_from_json_appid_extension_invalid_non_https() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
@@ -859,7 +1040,13 @@ mod tests {
r#"{"appid":"http://www.example.org/u2f/origins.json"}"#,
);
- let res = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let res = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await;
assert!(matches!(
res,
Err(GetAssertionRequestParsingError::InvalidAppId(_))
@@ -911,34 +1098,40 @@ mod tests {
assert_eq!(sign_requests[0].app_id_hash, rp_hash);
}
- fn parse_prf(extensions_json: &str) -> PrfInput {
+ async fn parse_prf(extensions_json: &str) -> PrfInput {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", extensions_json);
- let req = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .expect("request should parse");
+ let req = GetAssertionRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .expect("request should parse");
req.extensions
.expect("extensions")
.prf
.expect("prf extension")
}
- #[test]
- fn test_request_from_json_prf_extension() {
+ #[tokio::test]
+ async fn test_request_from_json_prf_extension() {
// Non-32-byte inputs must now parse (W3C WebAuthn L3 §10.1.4). "AQID"
// decodes to 0x010203, "BAUG" to 0x040506.
- let prf = parse_prf(r#"{"prf":{"eval":{"first":"AQID","second":"BAUG"}}}"#);
+ let prf = parse_prf(r#"{"prf":{"eval":{"first":"AQID","second":"BAUG"}}}"#).await;
let eval = prf.eval.expect("eval");
assert_eq!(eval.first, vec![0x01, 0x02, 0x03]);
assert_eq!(eval.second.as_deref(), Some(&[0x04u8, 0x05, 0x06][..]));
}
- #[test]
- fn test_prf_input_variable_length() {
+ #[tokio::test]
+ async fn test_prf_input_variable_length() {
// W3C WebAuthn L3 §10.1.4: PRF inputs are BufferSources of any length.
for len in [1usize, 16, 31, 33, 64, 256] {
let bytes = vec![0xABu8; len];
let b64 = base64_url::encode(&bytes);
- let prf = parse_prf(&format!(r#"{{"prf":{{"eval":{{"first":"{b64}"}}}}}}"#));
+ let prf = parse_prf(&format!(r#"{{"prf":{{"eval":{{"first":"{b64}"}}}}}}"#)).await;
let eval = prf.eval.unwrap();
assert_eq!(eval.first.len(), len, "len {len}");
assert_eq!(eval.first, bytes, "len {len}");
@@ -946,34 +1139,35 @@ mod tests {
}
}
- #[test]
- fn test_prf_input_short_via_json() {
+ #[tokio::test]
+ async fn test_prf_input_short_via_json() {
// Regression test for #209: a sub-32-byte salt encoded in the JSON IDL
// must round-trip through GetAssertionRequest::from_json into a
// PrfInputValue with the expected bytes.
- let prf = parse_prf(r#"{"prf":{"eval":{"first":"aGk"}}}"#); // base64url "aGk" -> b"hi"
+ let prf = parse_prf(r#"{"prf":{"eval":{"first":"aGk"}}}"#).await; // base64url "aGk" -> b"hi"
let eval = prf.eval.expect("eval");
assert_eq!(eval.first, b"hi");
assert!(eval.second.is_none());
}
- #[test]
- fn test_prf_input_empty_allowed() {
+ #[tokio::test]
+ async fn test_prf_input_empty_allowed() {
// §10.1.4 says "of any length" with no lower bound; empty must parse.
- let prf = parse_prf(r#"{"prf":{"eval":{"first":""}}}"#);
+ let prf = parse_prf(r#"{"prf":{"eval":{"first":""}}}"#).await;
let eval = prf.eval.unwrap();
assert!(eval.first.is_empty());
assert!(eval.second.is_none());
}
- #[test]
- fn test_prf_eval_by_credential_variable_length() {
+ #[tokio::test]
+ async fn test_prf_eval_by_credential_variable_length() {
// NOTE: the IDL field is currently deserialized as `eval_by_credential`
// rather than the spec name `evalByCredential` — separate concern from
// #209. Use the field name the deserializer accepts.
let prf = parse_prf(
r#"{"prf":{"eval_by_credential":{"Y3JlZDE":{"first":"AQ","second":"AgIC"}}}}"#,
- );
+ )
+ .await;
let v = prf.eval_by_credential.get("Y3JlZDE").expect("entry");
assert_eq!(v.first, vec![0x01]);
assert_eq!(v.second.as_deref(), Some(&[0x02u8, 0x02, 0x02][..]));
diff --git a/libwebauthn/src/ops/webauthn/idl/mod.rs b/libwebauthn/src/ops/webauthn/idl/mod.rs
index 49eb8377..7a366f09 100644
--- a/libwebauthn/src/ops/webauthn/idl/mod.rs
+++ b/libwebauthn/src/ops/webauthn/idl/mod.rs
@@ -14,45 +14,48 @@ pub use response::{
WebAuthnIDLResponse,
};
+use async_trait::async_trait;
use origin::RequestOrigin;
-
-use super::psl::PublicSuffixList;
-
use serde::de::DeserializeOwned;
use serde_json;
+use super::psl::PublicSuffixList;
+use super::related_origins::RelatedOriginsHttpClient;
+
pub type JsonError = serde_json::Error;
+#[async_trait]
pub trait WebAuthnIDL: Sized
where
- E: std::error::Error, // Validation error type.
+ E: std::error::Error,
Self: FromIdlModel,
{
- /// An error type that can be returned when deserializing from JSON, including
- /// JSON parsing errors and any additional validation errors.
type Error: std::error::Error + From + From;
+ type IdlModel: DeserializeOwned + Send;
- /// The JSON model that this IDL can deserialize from.
- type IdlModel: DeserializeOwned;
-
- fn from_json(
+ async fn from_json(
request_origin: &RequestOrigin,
psl: &dyn PublicSuffixList,
+ http: &dyn RelatedOriginsHttpClient,
json: &str,
) -> Result {
let idl_model: Self::IdlModel = serde_json::from_str(json)?;
- Self::from_idl_model(request_origin, psl, idl_model).map_err(From::from)
+ Self::from_idl_model(request_origin, psl, http, idl_model)
+ .await
+ .map_err(From::from)
}
}
+#[async_trait]
pub trait FromIdlModel: Sized
where
- T: DeserializeOwned,
+ T: DeserializeOwned + Send,
E: std::error::Error,
{
- fn from_idl_model(
+ async fn from_idl_model(
request_origin: &RequestOrigin,
psl: &dyn PublicSuffixList,
+ http: &dyn RelatedOriginsHttpClient,
model: T,
) -> Result;
}
diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs
index 2655bc91..952d93ef 100644
--- a/libwebauthn/src/ops/webauthn/make_credential.rs
+++ b/libwebauthn/src/ops/webauthn/make_credential.rs
@@ -1,5 +1,6 @@
use std::time::Duration;
+use async_trait::async_trait;
use ctap_types::ctap2::credential_management::CredentialProtectionPolicy as Ctap2CredentialProtectionPolicy;
use serde::{Deserialize, Deserializer, Serialize};
use sha2::{Digest, Sha256};
@@ -21,6 +22,7 @@ use crate::{
Base64UrlString, FromIdlModel, JsonError, WebAuthnIDL,
},
psl::PublicSuffixList,
+ related_origins::{validate_related_origins, RelatedOriginsHttpClient},
Operation, PrfInputValue, PrfOutputValue, RelyingPartyId, RequestOrigin,
},
proto::{
@@ -381,26 +383,32 @@ impl MakeCredentialRequest {
}
}
+#[async_trait]
impl FromIdlModel
for MakeCredentialRequest
{
- fn from_idl_model(
+ async fn from_idl_model(
request_origin: &RequestOrigin,
psl: &dyn PublicSuffixList,
+ http: &dyn RelatedOriginsHttpClient,
inner: PublicKeyCredentialCreationOptionsJSON,
) -> Result {
let effective_rp_id = request_origin.origin.host.as_str();
let rp_id = RelyingPartyId::try_from(inner.rp.id.as_str()).map_err(|err| {
MakeCredentialRequestParsingError::InvalidRelyingPartyId(err.to_string())
})?;
- // TODO(#160): Add related-origins fallback per WebAuthn L3 §5.11.
if !is_registrable_domain_suffix_or_equal(&rp_id.0, effective_rp_id, psl) {
- return Err(
- MakeCredentialRequestParsingError::MismatchingRelyingPartyId(
- rp_id.0,
- effective_rp_id.to_string(),
- ),
- );
+ if let Err(err) =
+ validate_related_origins(&request_origin.origin, &rp_id, psl, http).await
+ {
+ debug!(rp_id = %rp_id.0, %err, "Related-origins validation failed");
+ return Err(
+ MakeCredentialRequestParsingError::MismatchingRelyingPartyId(
+ rp_id.0,
+ effective_rp_id.to_string(),
+ ),
+ );
+ }
}
let mut relying_party = inner.rp;
relying_party.id = rp_id.0;
@@ -707,12 +715,57 @@ impl DowngradableRequest for MakeCredentialRequest {
mod tests {
use std::time::Duration;
+ use async_trait::async_trait;
+
use crate::ops::webauthn::psl::MockPublicSuffixList;
- use crate::ops::webauthn::{MakeCredentialRequest, RequestOrigin};
+ use crate::ops::webauthn::related_origins::{
+ RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse,
+ };
+ use crate::ops::webauthn::{MakeCredentialRequest, NoRelatedOriginsClient, RequestOrigin};
use crate::proto::ctap2::Ctap2PublicKeyCredentialType;
use super::*;
+ /// Test-only HTTP client backed by a fixed response. `panicking` proves the
+ /// suffix-check short-circuit by failing the test if the fetch is invoked.
+ struct MockHttpClient {
+ response: Option>,
+ }
+
+ impl MockHttpClient {
+ fn ok_body(body: &str) -> Self {
+ Self {
+ response: Some(Ok(WellKnownResponse {
+ content_type: Some("application/json".into()),
+ body: body.as_bytes().to_vec(),
+ })),
+ }
+ }
+
+ fn err(e: WellKnownFetchError) -> Self {
+ Self {
+ response: Some(Err(e)),
+ }
+ }
+
+ fn panicking() -> Self {
+ Self { response: None }
+ }
+ }
+
+ #[async_trait]
+ impl RelatedOriginsHttpClient for MockHttpClient {
+ async fn fetch_well_known(
+ &self,
+ _: &RelyingPartyId,
+ ) -> Result {
+ match &self.response {
+ Some(r) => r.clone(),
+ None => panic!("fetch_well_known should not be called"),
+ }
+ }
+ }
+
pub const REQUEST_BASE_JSON: &str = r#"
{
"rp": {
@@ -772,76 +825,93 @@ mod tests {
serde_json::to_string(&v).unwrap()
}
- fn test_request_from_json_required_field(field: &str) {
+ async fn test_request_from_json_required_field(field: &str) {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_rm(REQUEST_BASE_JSON, field);
- let result =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
Err(MakeCredentialRequestParsingError::EncodingError(_))
));
}
- #[test]
- fn test_request_from_json_base() {
+ #[tokio::test]
+ async fn test_request_from_json_base() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req: MakeCredentialRequest = MakeCredentialRequest::from_json(
&request_origin,
&MockPublicSuffixList,
+ &NoRelatedOriginsClient,
REQUEST_BASE_JSON,
)
+ .await
.unwrap();
assert_eq!(req, request_base());
}
- #[test]
- fn test_request_from_json_require_rp() {
- test_request_from_json_required_field("rp");
+ #[tokio::test]
+ async fn test_request_from_json_require_rp() {
+ test_request_from_json_required_field("rp").await;
}
- #[test]
- fn test_request_from_json_require_user() {
- test_request_from_json_required_field("user");
+ #[tokio::test]
+ async fn test_request_from_json_require_user() {
+ test_request_from_json_required_field("user").await;
}
- #[test]
- fn test_request_from_json_require_pub_key_cred_params() {
- test_request_from_json_required_field("pubKeyCredParams");
+ #[tokio::test]
+ async fn test_request_from_json_require_pub_key_cred_params() {
+ test_request_from_json_required_field("pubKeyCredParams").await;
}
- #[test]
- fn test_request_from_json_require_challenge() {
- test_request_from_json_required_field("challenge");
+ #[tokio::test]
+ async fn test_request_from_json_require_challenge() {
+ test_request_from_json_required_field("challenge").await;
}
- #[test]
+ #[tokio::test]
#[ignore] // FIXME(#134): Add validation for challenges
- fn test_request_from_json_challenge_empty() {
+ async fn test_request_from_json_challenge_empty() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json: String = json_field_rm(REQUEST_BASE_JSON, "challenge");
let req_json = json_field_add(&req_json, "challenge", r#""""#);
- let result =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
Err(MakeCredentialRequestParsingError::EncodingError(_))
));
}
- #[test]
- fn test_request_from_json_prf_extension() {
+ #[tokio::test]
+ async fn test_request_from_json_prf_extension() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let first = base64_url::encode(&[1u8; 32]);
let second = base64_url::encode(&[2u8; 32]);
let ext = format!(r#"{{"prf": {{"eval": {{"first": "{first}", "second": "{second}"}}}}}}"#);
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext);
- let req: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: MakeCredentialRequest = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
let prf = req
.extensions
.as_ref()
@@ -852,29 +922,39 @@ mod tests {
assert_eq!(prf.second, Some(vec![2u8; 32]));
}
- #[test]
- fn test_request_from_json_prf_extension_empty() {
+ #[tokio::test]
+ async fn test_request_from_json_prf_extension_empty() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", r#"{"prf": {}}"#);
- let req: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: MakeCredentialRequest = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
let prf = req.extensions.unwrap().prf.unwrap();
assert!(prf.eval.is_none());
}
- #[test]
- fn test_request_from_json_prf_extension_short_input() {
+ #[tokio::test]
+ async fn test_request_from_json_prf_extension_short_input() {
// WebAuthn L3 §10.1.4: PRF salt inputs are BufferSources of any length.
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let short = base64_url::encode(&[0u8; 16]);
let ext = format!(r#"{{"prf": {{"eval": {{"first": "{short}"}}}}}}"#);
let req_json = json_field_add(REQUEST_BASE_JSON, "extensions", &ext);
- let req: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: MakeCredentialRequest = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
let prf = req
.extensions
.as_ref()
@@ -885,8 +965,8 @@ mod tests {
assert!(prf.second.is_none());
}
- #[test]
- fn test_request_from_json_appid_exclude_extension() {
+ #[tokio::test]
+ async fn test_request_from_json_appid_exclude_extension() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
@@ -894,9 +974,14 @@ mod tests {
r#"{"appidExclude": "https://www.example.org/u2f/origins.json"}"#,
);
- let req: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: MakeCredentialRequest = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
let ext = req.extensions.expect("extensions should be present");
assert_eq!(
ext.appid_exclude.as_deref(),
@@ -904,17 +989,22 @@ mod tests {
);
}
- #[test]
- fn test_request_from_json_unknown_pub_key_cred_params() {
+ #[tokio::test]
+ async fn test_request_from_json_unknown_pub_key_cred_params() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
"pubKeyCredParams",
r#"[{"type": "something", "alg": -12345}]"#,
);
- let req: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: MakeCredentialRequest = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(
req.algorithms,
vec![Ctap2CredentialType {
@@ -924,27 +1014,37 @@ mod tests {
);
}
- #[test]
- fn test_request_from_json_default_timeout() {
+ #[tokio::test]
+ async fn test_request_from_json_default_timeout() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_rm(REQUEST_BASE_JSON, "timeout");
- let req: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: MakeCredentialRequest = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(req.timeout, DEFAULT_TIMEOUT);
}
/// Per spec, when authenticatorSelection is missing, userVerification should default to "preferred".
/// https://www.w3.org/TR/webauthn-3/#dom-authenticatorselectioncriteria-userverification
- #[test]
- fn test_request_from_json_default_user_verification_preferred() {
+ #[tokio::test]
+ async fn test_request_from_json_default_user_verification_preferred() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_rm(REQUEST_BASE_JSON, "authenticatorSelection");
- let req: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: MakeCredentialRequest = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(
req.user_verification,
UserVerificationRequirement::Preferred
@@ -953,8 +1053,8 @@ mod tests {
/// Per spec, when userVerification is missing inside authenticatorSelection,
/// it should default to "preferred".
- #[test]
- fn test_request_from_json_missing_user_verification_in_authenticator_selection() {
+ #[tokio::test]
+ async fn test_request_from_json_missing_user_verification_in_authenticator_selection() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
// Replace authenticatorSelection with one that has no userVerification field
let mut req_json = json_field_rm(REQUEST_BASE_JSON, "authenticatorSelection");
@@ -964,17 +1064,22 @@ mod tests {
r#"{"residentKey": "discouraged"}"#,
);
- let req: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: MakeCredentialRequest = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(
req.user_verification,
UserVerificationRequirement::Preferred
);
}
- #[test]
- fn test_request_from_json_invalid_rp_id() {
+ #[tokio::test]
+ async fn test_request_from_json_invalid_rp_id() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
@@ -982,16 +1087,21 @@ mod tests {
r#"{"id": "example.org.", "name": "example.org"}"#,
);
- let result =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
Err(MakeCredentialRequestParsingError::InvalidRelyingPartyId(_))
));
}
- #[test]
- fn test_request_from_json_mismatching_rp_id() {
+ #[tokio::test]
+ async fn test_request_from_json_mismatching_rp_id() {
let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
@@ -999,16 +1109,21 @@ mod tests {
r#"{"id": "other.example.org", "name": "example.org"}"#,
);
- let result =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _))
));
}
- #[test]
- fn test_request_from_json_rp_id_is_parent_registrable_suffix() {
+ #[tokio::test]
+ async fn test_request_from_json_rp_id_is_parent_registrable_suffix() {
let request_origin: RequestOrigin = "https://login.example.org".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
@@ -1016,15 +1131,20 @@ mod tests {
r#"{"id": "example.org", "name": "example.org"}"#,
);
- let req =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(req.relying_party.id, "example.org");
assert_eq!(req.origin, "https://login.example.org");
}
- #[test]
- fn test_request_from_json_rp_id_is_etld_rejected() {
+ #[tokio::test]
+ async fn test_request_from_json_rp_id_is_etld_rejected() {
let request_origin: RequestOrigin = "https://example.co.uk".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
@@ -1032,16 +1152,21 @@ mod tests {
r#"{"id": "co.uk", "name": "co.uk"}"#,
);
- let result =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _))
));
}
- #[test]
- fn test_request_from_json_http_localhost_accepted() {
+ #[tokio::test]
+ async fn test_request_from_json_http_localhost_accepted() {
let request_origin: RequestOrigin = "http://localhost".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
@@ -1049,15 +1174,20 @@ mod tests {
r#"{"id": "localhost", "name": "localhost"}"#,
);
- let req =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(req.relying_party.id, "localhost");
assert_eq!(req.origin, "http://localhost");
}
- #[test]
- fn test_request_from_json_http_localhost_with_port_accepted() {
+ #[tokio::test]
+ async fn test_request_from_json_http_localhost_with_port_accepted() {
let request_origin: RequestOrigin = "http://localhost:3000".parse().unwrap();
let req_json = json_field_add(
REQUEST_BASE_JSON,
@@ -1065,13 +1195,109 @@ mod tests {
r#"{"id": "localhost", "name": "localhost"}"#,
);
- let req =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &NoRelatedOriginsClient,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(req.relying_party.id, "localhost");
assert_eq!(req.origin, "http://localhost:3000");
}
+ // `.de` substituted with `.org` (MockPublicSuffixList lacks `.de`); pattern
+ // (different eTLD between caller origin and rp.id) is identical.
+
+ #[tokio::test]
+ async fn related_origins_match_resolves_mismatch() {
+ let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap();
+ let req_json = json_field_add(
+ REQUEST_BASE_JSON,
+ "rp",
+ r#"{"id": "example.com", "name": "example.com"}"#,
+ );
+ let http = MockHttpClient::ok_body(r#"{"origins":["https://app.example.org"]}"#);
+
+ let req = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &http,
+ &req_json,
+ )
+ .await
+ .unwrap();
+ assert_eq!(req.relying_party.id, "example.com");
+ }
+
+ #[tokio::test]
+ async fn related_origins_no_match_keeps_mismatch_error() {
+ let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap();
+ let req_json = json_field_add(
+ REQUEST_BASE_JSON,
+ "rp",
+ r#"{"id": "example.com", "name": "example.com"}"#,
+ );
+ let http = MockHttpClient::ok_body(r#"{"origins":["https://other.org"]}"#);
+
+ let result = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &http,
+ &req_json,
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _))
+ ));
+ }
+
+ #[tokio::test]
+ async fn related_origins_fetch_error_keeps_mismatch_error() {
+ let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap();
+ let req_json = json_field_add(
+ REQUEST_BASE_JSON,
+ "rp",
+ r#"{"id": "example.com", "name": "example.com"}"#,
+ );
+ let http = MockHttpClient::err(WellKnownFetchError::Transport("simulated".into()));
+
+ let result = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &http,
+ &req_json,
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _))
+ ));
+ }
+
+ #[tokio::test]
+ async fn related_origins_not_consulted_when_suffix_matches() {
+ let request_origin: RequestOrigin = "https://login.example.com".parse().unwrap();
+ let req_json = json_field_add(
+ REQUEST_BASE_JSON,
+ "rp",
+ r#"{"id": "example.com", "name": "example.com"}"#,
+ );
+ let http = MockHttpClient::panicking();
+
+ let req = MakeCredentialRequest::from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ &http,
+ &req_json,
+ )
+ .await
+ .unwrap();
+ assert_eq!(req.relying_party.id, "example.com");
+ }
+
// Tests for response JSON serialization
fn create_test_response() -> MakeCredentialResponse {
diff --git a/libwebauthn/src/ops/webauthn/mod.rs b/libwebauthn/src/ops/webauthn/mod.rs
index 767c6c0a..16ddd77d 100644
--- a/libwebauthn/src/ops/webauthn/mod.rs
+++ b/libwebauthn/src/ops/webauthn/mod.rs
@@ -3,6 +3,7 @@ mod get_assertion;
pub mod idl;
mod make_credential;
pub mod psl;
+pub mod related_origins;
mod timeout;
use super::u2f::{RegisterRequest, SignRequest};
@@ -36,6 +37,12 @@ pub use psl::{
PublicSuffixList, SystemLoadError, SystemPublicSuffixList, SYSTEM_PSL_DAFSA_PATH,
SYSTEM_PSL_PATH,
};
+pub use related_origins::{
+ validate_related_origins, NoRelatedOriginsClient, RelatedOriginsError,
+ RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse,
+};
+#[cfg(feature = "related-origins-client")]
+pub use related_origins::{HttpPolicy, ReqwestRelatedOriginsClient};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, Deserialize, PartialEq)]
diff --git a/libwebauthn/src/ops/webauthn/related_origins/http.rs b/libwebauthn/src/ops/webauthn/related_origins/http.rs
new file mode 100644
index 00000000..74443a53
--- /dev/null
+++ b/libwebauthn/src/ops/webauthn/related_origins/http.rs
@@ -0,0 +1,102 @@
+//! reqwest-backed [`RelatedOriginsHttpClient`]. Gated by the
+//! `related-origins-client` cargo feature.
+
+use std::time::Duration;
+
+use async_trait::async_trait;
+use futures::StreamExt;
+use reqwest::redirect::Policy;
+use reqwest::{Client, StatusCode};
+
+use super::{RelatedOriginsHttpClient, WellKnownFetchError, WellKnownResponse};
+use crate::ops::webauthn::idl::rpid::RelyingPartyId;
+
+#[derive(Debug, Clone)]
+pub struct HttpPolicy {
+ pub request_timeout: Duration,
+ pub max_body_bytes: usize,
+ pub max_redirects: usize,
+}
+
+impl Default for HttpPolicy {
+ fn default() -> Self {
+ Self {
+ request_timeout: Duration::from_secs(10),
+ max_body_bytes: 256 * 1024,
+ max_redirects: 5,
+ }
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct ReqwestRelatedOriginsClient {
+ client: Client,
+ max_body_bytes: usize,
+}
+
+impl ReqwestRelatedOriginsClient {
+ pub fn new() -> Result {
+ Self::with_policy(HttpPolicy::default())
+ }
+
+ pub fn with_policy(policy: HttpPolicy) -> Result {
+ let max_redirects = policy.max_redirects;
+ let redirect_policy = Policy::custom(move |attempt| {
+ if attempt.previous().len() >= max_redirects {
+ return attempt.error("redirect limit exceeded");
+ }
+ if attempt.url().scheme() != "https" {
+ return attempt.error("non-https redirect");
+ }
+ attempt.follow()
+ });
+ // WebAuthn L3 §5.11.1 step 2: fetch "without a referrer"; `cookies`
+ // feature is off, so reqwest holds no cookie jar.
+ let client = Client::builder()
+ .https_only(true)
+ .redirect(redirect_policy)
+ .referer(false)
+ .timeout(policy.request_timeout)
+ .build()
+ .map_err(|e| WellKnownFetchError::Transport(e.to_string()))?;
+ Ok(Self {
+ client,
+ max_body_bytes: policy.max_body_bytes,
+ })
+ }
+}
+
+#[async_trait]
+impl RelatedOriginsHttpClient for ReqwestRelatedOriginsClient {
+ async fn fetch_well_known(
+ &self,
+ rp_id: &RelyingPartyId,
+ ) -> Result {
+ let url = format!("https://{}/.well-known/webauthn", rp_id.0);
+ let response = self
+ .client
+ .get(&url)
+ .send()
+ .await
+ .map_err(|e| WellKnownFetchError::Transport(e.to_string()))?;
+ if response.status() != StatusCode::OK {
+ return Err(WellKnownFetchError::Status(response.status().as_u16()));
+ }
+ let content_type = response
+ .headers()
+ .get(reqwest::header::CONTENT_TYPE)
+ .and_then(|v| v.to_str().ok())
+ .map(str::to_owned);
+
+ let mut body = Vec::with_capacity(8 * 1024);
+ let mut stream = response.bytes_stream();
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk.map_err(|e| WellKnownFetchError::Transport(e.to_string()))?;
+ if body.len() + chunk.len() > self.max_body_bytes {
+ return Err(WellKnownFetchError::BodyTooLarge);
+ }
+ body.extend_from_slice(&chunk);
+ }
+ Ok(WellKnownResponse { content_type, body })
+ }
+}
diff --git a/libwebauthn/src/ops/webauthn/related_origins/mod.rs b/libwebauthn/src/ops/webauthn/related_origins/mod.rs
new file mode 100644
index 00000000..f692dc4e
--- /dev/null
+++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs
@@ -0,0 +1,744 @@
+//! Related-origins validation (WebAuthn L3 §5.11).
+//!
+//! The HTTP fetch of the `webauthn` well-known document is abstracted behind
+//! [`RelatedOriginsHttpClient`]; a reqwest-backed default lives in [`http`]
+//! behind the `related-origins-client` cargo feature.
+
+use std::collections::BTreeSet;
+
+use async_trait::async_trait;
+use serde::Deserialize;
+use url::{Host, Url};
+
+use super::idl::origin::{Origin, Scheme};
+use super::idl::rpid::RelyingPartyId;
+use super::psl::PublicSuffixList;
+
+#[cfg(feature = "related-origins-client")]
+pub mod http;
+
+#[cfg(feature = "related-origins-client")]
+pub use http::{HttpPolicy, ReqwestRelatedOriginsClient};
+
+/// WebAuthn L3 §5.11 minimum; capped at 5 to bound abuse surface.
+const MAX_REGISTRABLE_LABELS: usize = 5;
+
+#[derive(Debug, Clone)]
+pub struct WellKnownResponse {
+ pub content_type: Option,
+ pub body: Vec,
+}
+
+/// Fetcher for `https://{rp_id}/.well-known/webauthn`, per WebAuthn L3 §5.11.1
+/// step 2. Implementations MUST send no credentials, no Referer, refuse
+/// non-`https://` redirects, cap the body size, and bound the request duration.
+/// Implementations MUST return `Err(WellKnownFetchError::Status)` for any
+/// status code other than 200 (after following redirects). Implementations MUST
+/// report the wire `Content-Type` header value unmodified (or `None` if absent)
+/// and MUST NOT synthesise an `application/json` content type for non-JSON
+/// responses.
+#[async_trait]
+pub trait RelatedOriginsHttpClient: Send + Sync {
+ async fn fetch_well_known(
+ &self,
+ rp_id: &RelyingPartyId,
+ ) -> Result;
+}
+
+/// Failure modes for [`RelatedOriginsHttpClient::fetch_well_known`].
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum WellKnownFetchError {
+ /// Transport-level failure: TLS, DNS, timeout, rejected redirect, body
+ /// stream interrupt, client build error, etc.
+ #[error("transport error: {0}")]
+ Transport(String),
+ /// Endpoint replied with a non-200 status (after following redirects).
+ #[error("HTTP status {0}")]
+ Status(u16),
+ /// Body exceeded the implementation's configured size cap before completion.
+ #[error("body exceeded configured size cap")]
+ BodyTooLarge,
+ /// Implementation does not perform fetches (see [`NoRelatedOriginsClient`]).
+ #[error("client does not support related-origin fetches")]
+ NotSupported,
+}
+
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum RelatedOriginsError {
+ #[error("well-known fetch failed: {0}")]
+ Fetch(#[from] WellKnownFetchError),
+ #[error("unexpected content type: {0:?}")]
+ UnexpectedContentType(Option),
+ /// Step 2.b: body did not decode as JSON.
+ #[error("malformed JSON body: {0}")]
+ MalformedJson(String),
+ /// Step 2.c: top-level `origins` was missing or not an array of strings.
+ #[error("malformed well-known document: {0}")]
+ MalformedDocument(String),
+ #[error("no listed related origin matches the caller origin")]
+ NoMatchingOrigin,
+}
+
+pub type RelatedOriginsResult = Result<(), RelatedOriginsError>;
+
+#[derive(Debug, Deserialize)]
+struct WellKnownDocument {
+ origins: Vec,
+}
+
+/// Runs the WebAuthn L3 §5.11.1 related-origins validation procedure.
+pub async fn validate_related_origins(
+ caller_origin: &Origin,
+ rp_id: &RelyingPartyId,
+ psl: &dyn PublicSuffixList,
+ http: &dyn RelatedOriginsHttpClient,
+) -> RelatedOriginsResult {
+ let resp = http.fetch_well_known(rp_id).await?;
+ let content_type_ok = resp
+ .content_type
+ .as_deref()
+ .map(is_application_json)
+ .unwrap_or(false);
+ if !content_type_ok {
+ return Err(RelatedOriginsError::UnexpectedContentType(
+ resp.content_type,
+ ));
+ }
+
+ let value: serde_json::Value = serde_json::from_slice(&resp.body)
+ .map_err(|e| RelatedOriginsError::MalformedJson(e.to_string()))?;
+ if !value.is_object() {
+ return Err(RelatedOriginsError::MalformedJson(
+ "top-level value is not a JSON object".into(),
+ ));
+ }
+ let doc: WellKnownDocument = serde_json::from_value(value)
+ .map_err(|e| RelatedOriginsError::MalformedDocument(e.to_string()))?;
+
+ let mut labels_seen: BTreeSet = BTreeSet::new();
+ for origin_item in &doc.origins {
+ let Ok(url) = Url::parse(origin_item) else {
+ continue;
+ };
+ let Some(domain) = effective_domain_of(&url) else {
+ continue;
+ };
+ let label = match registrable_origin_label(&domain, psl) {
+ Some(l) if !l.is_empty() => l,
+ _ => continue,
+ };
+ if labels_seen.len() >= MAX_REGISTRABLE_LABELS && !labels_seen.contains(&label) {
+ continue;
+ }
+ if same_origin(caller_origin, &url) {
+ return Ok(());
+ }
+ if labels_seen.len() < MAX_REGISTRABLE_LABELS {
+ labels_seen.insert(label);
+ }
+ }
+
+ Err(RelatedOriginsError::NoMatchingOrigin)
+}
+
+/// First label of `host`'s registrable domain (eTLD+1), or `None` if `host` has no registrable domain.
+pub(crate) fn registrable_origin_label(host: &str, psl: &dyn PublicSuffixList) -> Option {
+ let registrable = psl.registrable_domain(host)?;
+ let label = registrable.split('.').next()?;
+ if label.is_empty() {
+ return None;
+ }
+ Some(label.to_string())
+}
+
+/// Effective domain of `url` per HTML §6.2; `None` for opaque or host-less URLs.
+fn effective_domain_of(url: &Url) -> Option {
+ match url.host()? {
+ Host::Domain(d) => Some(d.to_string()),
+ Host::Ipv4(ip) => Some(ip.to_string()),
+ Host::Ipv6(ip) => Some(format!("[{ip}]")),
+ }
+}
+
+/// Tuple-origin equality (scheme, host, port) per WebAuthn L3 §5.11.1 step 4.f and HTML §7.5; userinfo, path, query and fragment on `listed` are ignored.
+fn same_origin(caller: &Origin, listed: &Url) -> bool {
+ if caller.scheme.as_str() != listed.scheme() {
+ return false;
+ }
+ let Some(listed_host) = effective_domain_of(listed) else {
+ return false;
+ };
+ if caller.host.as_str() != listed_host {
+ return false;
+ }
+ let caller_port = caller.port.or_else(|| default_port(caller.scheme));
+ caller_port == listed.port_or_known_default()
+}
+
+/// Default port per the WHATWG URL Standard special-scheme port table.
+fn default_port(scheme: Scheme) -> Option {
+ match scheme {
+ Scheme::Https => Some(443),
+ Scheme::Http => Some(80),
+ }
+}
+
+/// Fetch §2.5 `application/json` essence check; used for WebAuthn L3 §5.11.1 step 2.a.
+fn is_application_json(value: &str) -> bool {
+ let essence = value.split(';').next().unwrap_or("").trim();
+ essence.eq_ignore_ascii_case("application/json")
+}
+
+/// `RelatedOriginsHttpClient` that always refuses; preserves strict rp.id matching when callers do not opt into related-origin fetches.
+#[derive(Debug, Clone, Copy, Default)]
+pub struct NoRelatedOriginsClient;
+
+#[async_trait]
+impl RelatedOriginsHttpClient for NoRelatedOriginsClient {
+ async fn fetch_well_known(
+ &self,
+ _: &RelyingPartyId,
+ ) -> Result {
+ Err(WellKnownFetchError::NotSupported)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::psl::MockPublicSuffixList;
+ use super::*;
+
+ struct MockClient {
+ response: Result,
+ }
+
+ #[async_trait]
+ impl RelatedOriginsHttpClient for MockClient {
+ async fn fetch_well_known(
+ &self,
+ _: &RelyingPartyId,
+ ) -> Result {
+ self.response.clone()
+ }
+ }
+
+ fn json_ct(body: &str) -> WellKnownResponse {
+ WellKnownResponse {
+ content_type: Some("application/json".into()),
+ body: body.as_bytes().to_vec(),
+ }
+ }
+
+ fn caller(s: &str) -> Origin {
+ Origin::try_from(s).unwrap()
+ }
+
+ fn rp(s: &str) -> RelyingPartyId {
+ RelyingPartyId::try_from(s).unwrap()
+ }
+
+ #[test]
+ fn registrable_origin_label_basic() {
+ let psl = MockPublicSuffixList;
+ assert_eq!(
+ registrable_origin_label("example.co.uk", &psl).as_deref(),
+ Some("example"),
+ );
+ assert_eq!(
+ registrable_origin_label("www.example.org", &psl).as_deref(),
+ Some("example"),
+ );
+ assert_eq!(registrable_origin_label("co.uk", &psl), None);
+ assert_eq!(registrable_origin_label("localhost", &psl), None);
+ }
+
+ #[test]
+ fn registrable_origin_label_ipv4_is_none() {
+ let psl = MockPublicSuffixList;
+ assert_eq!(registrable_origin_label("127.0.0.1", &psl), None);
+ }
+
+ #[tokio::test]
+ async fn same_origin_caller_listed_first() {
+ let body = r#"{"origins":["https://example.com"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn same_origin_with_port_match() {
+ let body = r#"{"origins":["https://example.com:8443"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com:8443"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn same_origin_with_port_mismatch_rejected() {
+ let body = r#"{"origins":["https://example.com:8443"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin)));
+ }
+
+ #[tokio::test]
+ async fn same_origin_default_port_normalised() {
+ let body = r#"{"origins":["https://example.com:443"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn caller_listed_after_other_origins() {
+ // Substituted `.de` with `.net` (MockPublicSuffixList lacks `.de`).
+ let body = r#"{"origins":["https://other.net","https://example.com"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn label_cap_allows_fifth_distinct_label_match() {
+ // §5.11.1 step 4.e: the 5th distinct label is still within the cap.
+ let body = r#"{"origins":[
+ "https://a.com",
+ "https://b.com",
+ "https://c.com",
+ "https://d.com",
+ "https://example.com"
+ ]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn label_cap_blocks_sixth_distinct_label_match() {
+ // §5.11.1 step 4.e: a sixth distinct label is silently skipped, so the
+ // would-be match never reaches step 4.f.
+ let body = r#"{"origins":[
+ "https://a.com",
+ "https://b.com",
+ "https://c.com",
+ "https://d.com",
+ "https://e.com",
+ "https://example.com"
+ ]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin)));
+ }
+
+ #[tokio::test]
+ async fn label_cap_allows_repeats_of_seen_label() {
+ // §5.11.1 step 4.e "contains label" exception: once `example` has been
+ // recorded, further `example`-label origins still proceed to step 4.f.
+ let body = r#"{"origins":[
+ "https://a.example.com",
+ "https://b.example.com",
+ "https://c.example.com",
+ "https://d.example.com",
+ "https://e.example.com",
+ "https://login.example.com"
+ ]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://login.example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn same_origin_https_vs_http_rejected() {
+ // `http://example.com` is rejected by `Origin::try_from` (non-localhost),
+ // so the listed entry can never be same-origin with the https caller.
+ let body = r#"{"origins":["http://example.com"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin)));
+ }
+
+ #[tokio::test]
+ async fn unparseable_origin_item_skipped() {
+ let body = r#"{"origins":["not a url","https://example.com"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn non_https_origin_item_skipped_not_rejected() {
+ let body = r#"{"origins":["data:text/plain,foo","https://example.com"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn unknown_suffix_origin_skipped() {
+ // `internal.localhost` has no registrable domain in MockPSL.
+ let body = r#"{"origins":["https://internal.localhost","https://example.com"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn bare_etld_origin_skipped() {
+ // §5.11.1 step 4.c returns None for `co.uk`.
+ let body = r#"{"origins":["https://co.uk","https://example.co.uk"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.co.uk"),
+ &rp("example.co.uk"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn wrong_content_type_rejected() {
+ let http = MockClient {
+ response: Ok(WellKnownResponse {
+ content_type: Some("text/html".into()),
+ body: b"{\"origins\":[]}".to_vec(),
+ }),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(
+ res,
+ Err(RelatedOriginsError::UnexpectedContentType(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn missing_content_type_rejected() {
+ let http = MockClient {
+ response: Ok(WellKnownResponse {
+ content_type: None,
+ body: b"{\"origins\":[]}".to_vec(),
+ }),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(
+ res,
+ Err(RelatedOriginsError::UnexpectedContentType(None))
+ ));
+ }
+
+ #[tokio::test]
+ async fn content_type_with_charset_accepted() {
+ let http = MockClient {
+ response: Ok(WellKnownResponse {
+ content_type: Some("application/json; charset=utf-8".into()),
+ body: br#"{"origins":["https://elsewhere.com"]}"#.to_vec(),
+ }),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin)));
+ }
+
+ #[tokio::test]
+ async fn content_type_case_insensitive() {
+ let http = MockClient {
+ response: Ok(WellKnownResponse {
+ content_type: Some("Application/JSON".into()),
+ body: br#"{"origins":["https://example.com"]}"#.to_vec(),
+ }),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn malformed_json_rejected() {
+ let http = MockClient {
+ response: Ok(json_ct("{not json}")),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Err(RelatedOriginsError::MalformedJson(_))));
+ }
+
+ #[tokio::test]
+ async fn non_object_json_rejected() {
+ let http = MockClient {
+ response: Ok(json_ct("[1,2,3]")),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Err(RelatedOriginsError::MalformedJson(_))));
+ }
+
+ #[tokio::test]
+ async fn missing_origins_key_rejected() {
+ let http = MockClient {
+ response: Ok(json_ct("{}")),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(
+ res,
+ Err(RelatedOriginsError::MalformedDocument(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn origins_not_array_rejected() {
+ let http = MockClient {
+ response: Ok(json_ct(r#"{"origins":"https://example.com"}"#)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(
+ res,
+ Err(RelatedOriginsError::MalformedDocument(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn origins_array_of_non_strings_rejected() {
+ let http = MockClient {
+ response: Ok(json_ct(r#"{"origins":[1,2,3]}"#)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(
+ res,
+ Err(RelatedOriginsError::MalformedDocument(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn empty_origins_array_no_match() {
+ let http = MockClient {
+ response: Ok(json_ct(r#"{"origins":[]}"#)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin)));
+ }
+
+ #[tokio::test]
+ async fn fetch_error_propagates_as_fetch() {
+ let http = MockClient {
+ response: Err(WellKnownFetchError::Transport("simulated".into())),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(
+ res,
+ Err(RelatedOriginsError::Fetch(WellKnownFetchError::Transport(
+ _
+ )))
+ ));
+ }
+
+ #[tokio::test]
+ async fn no_match_returns_no_matching_origin() {
+ let http = MockClient {
+ response: Ok(json_ct(r#"{"origins":["https://elsewhere.com"]}"#)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin)));
+ }
+
+ #[tokio::test]
+ async fn listed_origin_with_path_still_matches() {
+ // §5.11.1 step 4.f: same-origin compares (scheme, host, port) only, so
+ // a trailing path on the listed entry must not block the match.
+ let body = r#"{"origins":["https://example.com/foo"]}"#;
+ let http = MockClient {
+ response: Ok(json_ct(body)),
+ };
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[tokio::test]
+ async fn ipv6_listed_origin_skipped_no_registrable_label() {
+ // IPv6 host has no registrable label; loop skips at step 4.c/4.d.
+ let http = MockClient {
+ response: Ok(json_ct(r#"{"origins":["https://[::1]"]}"#)),
+ };
+ let res = validate_related_origins(
+ &caller("https://[::1]"),
+ &rp("example.com"),
+ &MockPublicSuffixList,
+ &http,
+ )
+ .await;
+ assert!(matches!(res, Err(RelatedOriginsError::NoMatchingOrigin)));
+ }
+}
diff --git a/libwebauthn/tests/related_origins.rs b/libwebauthn/tests/related_origins.rs
new file mode 100644
index 00000000..024ac22b
--- /dev/null
+++ b/libwebauthn/tests/related_origins.rs
@@ -0,0 +1,120 @@
+//! End-to-end related-origins integration tests (WebAuthn L3 §5.11).
+//!
+//! Drives `MakeCredentialRequest::from_json` / `GetAssertionRequest::from_json`
+//! with a mock HTTP client and a tiny inline PSL impl. No network.
+
+use async_trait::async_trait;
+
+use libwebauthn::ops::webauthn::{
+ GetAssertionRequest, MakeCredentialRequest, PublicSuffixList, RelatedOriginsHttpClient,
+ RelyingPartyId, RequestOrigin, WebAuthnIDL, WellKnownFetchError, WellKnownResponse,
+};
+
+const KNOWN_SUFFIXES: &[&str] = &["com", "org"];
+
+/// Minimal PSL recognising only `com` and `org`. Sufficient for these tests.
+struct TestPsl;
+
+impl PublicSuffixList for TestPsl {
+ fn public_suffix(&self, host: &str) -> Option {
+ for suffix in KNOWN_SUFFIXES {
+ if host == *suffix {
+ return Some((*suffix).to_string());
+ }
+ let needle = format!(".{suffix}");
+ if host.ends_with(&needle) {
+ return Some((*suffix).to_string());
+ }
+ }
+ None
+ }
+}
+
+struct StaticHttp {
+ body: &'static str,
+}
+
+#[async_trait]
+impl RelatedOriginsHttpClient for StaticHttp {
+ async fn fetch_well_known(
+ &self,
+ _: &RelyingPartyId,
+ ) -> Result {
+ Ok(WellKnownResponse {
+ content_type: Some("application/json".into()),
+ body: self.body.as_bytes().to_vec(),
+ })
+ }
+}
+
+const MAKE_CREDENTIAL_JSON: &str = r#"
+{
+ "rp": {"id": "example.org", "name": "example.org"},
+ "user": {
+ "id": "dXNlcmlk",
+ "name": "mario.rossi",
+ "displayName": "Mario Rossi"
+ },
+ "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu",
+ "pubKeyCredParams": [{"type": "public-key", "alg": -7}],
+ "timeout": 30000,
+ "excludeCredentials": [],
+ "authenticatorSelection": {
+ "residentKey": "discouraged",
+ "userVerification": "preferred"
+ },
+ "attestation": "none"
+}
+"#;
+
+const GET_ASSERTION_JSON: &str = r#"
+{
+ "challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu",
+ "timeout": 30000,
+ "rpId": "example.org",
+ "allowCredentials": [
+ {"type": "public-key", "id": "bXktY3JlZGVudGlhbC1pZA"}
+ ],
+ "userVerification": "preferred"
+}
+"#;
+
+// Caller and rp.id sit on different eTLDs (`example.com` vs `example.org`),
+// matching the §8.3 design example so the related-origins fetch path is
+// actually exercised.
+const WELL_KNOWN_BODY: &str = r#"{"origins":["https://app.example.com","https://example.org"]}"#;
+
+#[tokio::test]
+async fn end_to_end_mock_match_via_make_credential() {
+ let request_origin: RequestOrigin = "https://app.example.com".parse().unwrap();
+ let http = StaticHttp {
+ body: WELL_KNOWN_BODY,
+ };
+
+ let req =
+ MakeCredentialRequest::from_json(&request_origin, &TestPsl, &http, MAKE_CREDENTIAL_JSON)
+ .await
+ .unwrap();
+
+ assert_eq!(req.relying_party.id, "example.org");
+ assert!(req
+ .client_data_json()
+ .contains(r#""origin":"https://app.example.com""#));
+}
+
+#[tokio::test]
+async fn end_to_end_mock_match_via_get_assertion() {
+ let request_origin: RequestOrigin = "https://app.example.com".parse().unwrap();
+ let http = StaticHttp {
+ body: WELL_KNOWN_BODY,
+ };
+
+ let req = GetAssertionRequest::from_json(&request_origin, &TestPsl, &http, GET_ASSERTION_JSON)
+ .await
+ .unwrap();
+
+ assert_eq!(req.relying_party_id, "example.org");
+ assert!(req
+ .client_data_json()
+ .contains(r#""origin":"https://app.example.com""#));
+}