diff --git a/Cargo.lock b/Cargo.lock
index e3fcfbfd..5a4a754d 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"
@@ -1026,6 +1032,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"
@@ -1159,6 +1174,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"
@@ -1302,8 +1323,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi",
+ "wasm-bindgen",
]
[[package]]
@@ -1313,9 +1336,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]]
@@ -1369,6 +1394,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"
@@ -1504,12 +1548,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"
@@ -1662,6 +1789,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 = [
"hidapi",
"hkdf",
"hmac 0.12.1",
+ "http",
"idna",
"maplit",
"mockall",
@@ -1845,6 +1979,7 @@ dependencies = [
"publicsuffix",
"qrcode",
"rand 0.8.6",
+ "reqwest",
"rustls",
"serde",
"serde-indexed 0.2.0",
@@ -1970,6 +2105,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"
@@ -1997,6 +2138,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"
@@ -2542,6 +2689,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"
@@ -2666,6 +2868,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"
@@ -2765,6 +3011,7 @@ version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
+ "web-time",
"zeroize",
]
@@ -2785,6 +3032,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"
@@ -2988,6 +3241,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"
@@ -3210,6 +3475,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"
@@ -3386,6 +3660,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"
@@ -3495,6 +3784,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"
@@ -3660,6 +3994,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"
@@ -3783,6 +4123,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"
@@ -3820,6 +4169,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"
@@ -3874,6 +4233,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"
@@ -3886,6 +4258,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..574e72b3 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 --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` |
[^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 ceremony examples run with related origins disabled (they are same-origin, so it never applies). The bundled reqwest-backed [related-origins](https://www.w3.org/TR/webauthn-3/#sctn-related-origins) source is shown in the `webauthn_related_origins_hid` example below, behind the optional `reqwest-related-origins-source` feature. Consumers that ship their own HTTP stack can implement `HttpClient` or `RelatedOriginsSource` directly.
+
Additional HID-only examples cover specific FIDO2 features and authenticator management:
```
@@ -88,6 +90,9 @@ $ cargo run --example webauthn_prf_hid
$ cargo run --example prf_replay -- CREDENTIAL_ID FIRST_PRF_INPUT
$ cargo run --example device_selection_hid
+# Related origins (reqwest-backed well-known fetch)
+$ cargo run --features reqwest-related-origins-source --example webauthn_related_origins_hid
+
# CTAP2 authenticator management
$ cargo run --example change_pin_hid
$ cargo run --example bio_enrollment_hid
diff --git a/libwebauthn/Cargo.toml b/libwebauthn/Cargo.toml
index 0921dff3..78b6490b 100644
--- a/libwebauthn/Cargo.toml
+++ b/libwebauthn/Cargo.toml
@@ -39,6 +39,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 HttpClient and ReqwestRelatedOriginsSource. Off by
+# default so the core crate stays HTTP-client-free. Consumers that want the
+# default fetch opt in, others implement HttpClient or RelatedOriginsSource.
+reqwest-related-origins-source = ["dep:reqwest"]
[dependencies]
base64-url = "3.0.0"
@@ -47,6 +51,7 @@ tracing = "0.1.29"
idna = "1.0.3"
publicsuffix = "2.3"
url = "2.5"
+http = "1"
maplit = "1.0.2"
sha2 = "0.10.2"
uuid = { version = "1.5.0", features = ["serde", "v4"] }
@@ -100,6 +105,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"] }
@@ -161,6 +172,11 @@ path = "examples/features/webauthn_preflight_hid.rs"
name = "webauthn_prf_hid"
path = "examples/features/webauthn_prf_hid.rs"
+[[example]]
+name = "webauthn_related_origins_hid"
+path = "examples/features/webauthn_related_origins_hid.rs"
+required-features = ["reqwest-related-origins-source"]
+
[[example]]
name = "prf_replay"
path = "examples/features/prf_replay.rs"
diff --git a/libwebauthn/examples/ceremony/webauthn_ble.rs b/libwebauthn/examples/ceremony/webauthn_ble.rs
index f46fbc01..0f9df727 100644
--- a/libwebauthn/examples/ceremony/webauthn_ble.rs
+++ b/libwebauthn/examples/ceremony/webauthn_ble.rs
@@ -1,8 +1,8 @@
use std::error::Error;
use libwebauthn::ops::webauthn::{
- DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin,
- WebAuthnIDL as _, WebAuthnIDLResponse as _,
+ DatFilePublicSuffixList, GetAssertionRequest, JsonFormat, MakeCredentialRequest,
+ OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings, WebAuthnIDLResponse as _,
};
use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor;
use libwebauthn::transport::ble::list_devices;
@@ -52,8 +52,15 @@ pub async fn main() -> Result<(), Box> {
"attestation": "none"
}
"#;
+ let settings = RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: &psl,
+ related_origins: RelatedOrigins::Disabled,
+ },
+ };
let make_credentials_request: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &psl, request_json)
+ MakeCredentialRequest::prepare(&request_origin, request_json, &settings)
+ .await
.expect("Failed to parse request JSON");
println!(
"WebAuthn MakeCredential request: {:?}",
@@ -97,7 +104,8 @@ pub async fn main() -> Result<(), Box> {
"#
);
let get_assertion: GetAssertionRequest =
- GetAssertionRequest::from_json(&request_origin, &psl, &request_json)
+ GetAssertionRequest::prepare(&request_origin, &request_json, &settings)
+ .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..f00a9630 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, OriginValidation, RelatedOrigins,
+ RequestOrigin, RequestSettings, WebAuthnIDLResponse as _,
};
use libwebauthn::transport::{Channel as _, Device};
use libwebauthn::webauthn::WebAuthn;
@@ -58,6 +58,12 @@ 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 settings = RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: &psl,
+ related_origins: RelatedOrigins::Disabled,
+ },
+ };
let mut device: CableQrCodeDevice = CableQrCodeDevice::new_transient(
QrCodeOperationHint::MakeCredential,
@@ -79,8 +85,10 @@ 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::prepare(&request_origin, MAKE_CREDENTIAL_REQUEST, &settings)
+ .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..9c5b3876 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, OriginValidation, RelatedOrigins,
+ RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _,
};
use libwebauthn::transport::cable::channel::CableChannel;
use libwebauthn::transport::{Channel as _, Device};
@@ -95,9 +95,18 @@ 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::prepare(
+ &request_origin,
+ MAKE_CREDENTIAL_REQUEST,
+ &RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: &psl,
+ related_origins: RelatedOrigins::Disabled,
+ },
+ },
+ )
+ .await
+ .expect("Failed to parse request JSON");
let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
let response_json = response
@@ -155,8 +164,18 @@ 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::prepare(
+ request_origin,
+ GET_ASSERTION_REQUEST,
+ &RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: psl,
+ related_origins: RelatedOrigins::Disabled,
+ },
+ },
+ )
+ .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..4e69df06 100644
--- a/libwebauthn/examples/ceremony/webauthn_hid.rs
+++ b/libwebauthn/examples/ceremony/webauthn_hid.rs
@@ -2,8 +2,8 @@ use std::error::Error;
use std::time::Duration;
use libwebauthn::ops::webauthn::{
- GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList,
- WebAuthnIDL as _, WebAuthnIDLResponse as _,
+ GetAssertionRequest, JsonFormat, MakeCredentialRequest, OriginValidation, RelatedOrigins,
+ RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _,
};
use libwebauthn::proto::ctap2::Ctap2PublicKeyCredentialDescriptor;
use libwebauthn::transport::hid::list_devices;
@@ -32,6 +32,12 @@ 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 settings = RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: &psl,
+ related_origins: RelatedOrigins::Disabled,
+ },
+ };
let request_json = r#"
{
"rp": {
@@ -57,7 +63,8 @@ pub async fn main() -> Result<(), Box> {
}
"#;
let make_credentials_request: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &psl, request_json)
+ MakeCredentialRequest::prepare(&request_origin, request_json, &settings)
+ .await
.expect("Failed to parse request JSON");
println!(
"WebAuthn MakeCredential request: {:?}",
@@ -101,7 +108,8 @@ pub async fn main() -> Result<(), Box> {
"#
);
let get_assertion: GetAssertionRequest =
- GetAssertionRequest::from_json(&request_origin, &psl, &request_json)
+ GetAssertionRequest::prepare(&request_origin, &request_json, &settings)
+ .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..dc57ad00 100644
--- a/libwebauthn/examples/ceremony/webauthn_nfc.rs
+++ b/libwebauthn/examples/ceremony/webauthn_nfc.rs
@@ -1,8 +1,8 @@
use std::error::Error;
use libwebauthn::ops::webauthn::{
- GetAssertionRequest, JsonFormat, MakeCredentialRequest, RequestOrigin, SystemPublicSuffixList,
- WebAuthnIDL as _, WebAuthnIDLResponse as _,
+ GetAssertionRequest, JsonFormat, MakeCredentialRequest, OriginValidation, RelatedOrigins,
+ RequestOrigin, RequestSettings, SystemPublicSuffixList, WebAuthnIDLResponse as _,
};
use libwebauthn::transport::nfc::{get_nfc_device, is_nfc_available};
use libwebauthn::transport::{Channel as _, Device};
@@ -30,9 +30,14 @@ 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 make_credentials_request = MakeCredentialRequest::from_json(
+ let settings = RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: &psl,
+ related_origins: RelatedOrigins::Disabled,
+ },
+ };
+ let make_credentials_request = MakeCredentialRequest::prepare(
&request_origin,
- &psl,
r#"
{
"rp": {
@@ -57,7 +62,9 @@ pub async fn main() -> Result<(), Box> {
"attestation": "none"
}
"#,
+ &settings,
)
+ .await
.expect("Failed to parse request JSON");
println!(
"WebAuthn MakeCredential request: {:?}",
@@ -74,9 +81,8 @@ pub async fn main() -> Result<(), Box> {
.expect("Failed to serialize MakeCredential response");
println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
- let get_assertion = GetAssertionRequest::from_json(
+ let get_assertion = GetAssertionRequest::prepare(
&request_origin,
- &psl,
r#"
{
"challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu",
@@ -85,7 +91,9 @@ pub async fn main() -> Result<(), Box> {
"userVerification": "discouraged"
}
"#,
+ &settings,
)
+ .await
.expect("Failed to parse request JSON");
println!("WebAuthn GetAssertion request: {:?}", get_assertion);
diff --git a/libwebauthn/examples/features/webauthn_related_origins_hid.rs b/libwebauthn/examples/features/webauthn_related_origins_hid.rs
new file mode 100644
index 00000000..3ecb126f
--- /dev/null
+++ b/libwebauthn/examples/features/webauthn_related_origins_hid.rs
@@ -0,0 +1,85 @@
+//! HID make-credential with related origins enabled.
+//!
+//! The bundled reqwest-backed [`ReqwestRelatedOriginsSource`] fetches
+//! `https:///.well-known/webauthn` when the request's rp.id is not a
+//! registrable suffix of the caller origin. This demo is same-origin
+//! (`example.org`), so the source is wired but the fetch is not triggered. It
+//! fires when rp.id and the origin sit on different registrable domains, with
+//! the RP listing the caller origin in its well-known document.
+
+use std::error::Error;
+use std::time::Duration;
+
+use libwebauthn::ops::webauthn::{
+ JsonFormat, MakeCredentialRequest, MaxRegistrableLabels, OriginValidation, RelatedOrigins,
+ RequestOrigin, RequestSettings, ReqwestRelatedOriginsSource, SystemPublicSuffixList,
+ WebAuthnIDLResponse as _,
+};
+use libwebauthn::transport::hid::list_devices;
+use libwebauthn::transport::{Channel as _, Device};
+use libwebauthn::webauthn::WebAuthn;
+
+#[path = "../common/mod.rs"]
+mod common;
+
+const TIMEOUT: Duration = Duration::from_secs(10);
+
+#[tokio::main]
+pub async fn main() -> Result<(), Box> {
+ common::setup_logging();
+
+ let devices = list_devices().await.unwrap();
+ println!("Devices found: {:?}", devices);
+
+ for mut device in devices {
+ println!("Selected HID authenticator: {}", &device);
+ let mut channel = device.channel().await?;
+ channel.wink(TIMEOUT).await?;
+
+ let request_origin: RequestOrigin =
+ "https://example.org".try_into().expect("Invalid origin");
+ 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 = ReqwestRelatedOriginsSource::new()?;
+ let settings = RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: &psl,
+ related_origins: RelatedOrigins::Enabled {
+ source: &related_origins,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ },
+ };
+ let request_json = r#"
+ {
+ "rp": { "id": "example.org", "name": "Example Relying Party" },
+ "user": {
+ "id": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg",
+ "name": "alice",
+ "displayName": "Alice"
+ },
+ "challenge": "MTIzNDU2NzgxMjM0NTY3ODEyMzQ1Njc4MTIzNDU2Nzg",
+ "pubKeyCredParams": [{"type": "public-key", "alg": -7}],
+ "timeout": 60000,
+ "excludeCredentials": [],
+ "authenticatorSelection": { "residentKey": "discouraged", "userVerification": "preferred" },
+ "attestation": "none"
+ }
+ "#;
+ let request = MakeCredentialRequest::prepare(&request_origin, request_json, &settings)
+ .await
+ .expect("Failed to parse request JSON");
+
+ let state_recv = channel.get_ux_update_receiver();
+ tokio::spawn(common::handle_uv_updates(state_recv));
+
+ let response = retry_user_errors!(channel.webauthn_make_credential(&request)).unwrap();
+ let response_json = response
+ .to_json_string(&request, JsonFormat::Prettified)
+ .expect("Failed to serialize MakeCredential response");
+ println!("WebAuthn MakeCredential response (JSON):\n{response_json}");
+ }
+
+ Ok(())
+}
diff --git a/libwebauthn/src/fido.rs b/libwebauthn/src/fido.rs
index 68b4e16b..1d5f2cf3 100644
--- a/libwebauthn/src/fido.rs
+++ b/libwebauthn/src/fido.rs
@@ -1,3 +1,17 @@
+//! Protocol abstractions shared between FIDO2 (CTAP2) and FIDO U2F (CTAP1).
+//! This module models the common protocol surface of the two standards,
+//! including protocol negotiation via [`FidoProtocol`], revision tracking via
+//! [`FidoRevision`], and the [`AuthenticatorData`] structure returned by a
+//! device during authentication and attestation ceremonies.
+//!
+//! [`AuthenticatorData`] is the central type. It carries the relying party ID
+//! hash, the user presence and verification flags, the signature counter,
+//! optional attested credential data (the device AAGUID, credential ID, and
+//! credential public key), and any protocol extension outputs. The module
+//! serializes and deserializes these responses per the CTAP2 specification and
+//! preserves the COSE key bytes verbatim, so the device signatures a relying
+//! party verifies stay intact.
+
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use serde::{
de::{DeserializeOwned, Error as DesError, Visitor},
diff --git a/libwebauthn/src/lib.rs b/libwebauthn/src/lib.rs
index d7f4abd2..2aa0a380 100644
--- a/libwebauthn/src/lib.rs
+++ b/libwebauthn/src/lib.rs
@@ -1,3 +1,72 @@
+//! libwebauthn is a Linux platform library implementing the FIDO2/WebAuthn and
+//! FIDO U2F specifications. It provides traits for performing WebAuthn
+//! operations (make credential and get assertion) and U2F operations (register
+//! and sign) against authenticator devices connected over USB, Bluetooth, or
+//! optionally NFC. The core abstractions are the [`WebAuthn`](webauthn::WebAuthn)
+//! and [`U2F`](u2f::U2F) traits that drive the protocol logic, and the
+//! [`Channel`](transport::Channel) trait that abstracts communication with a
+//! physical authenticator.
+//!
+//! While an operation runs, the library may need user verification. It surfaces
+//! this through the [`UvUpdate`] enum and related types such as
+//! [`PinRequiredUpdate`] and [`PinNotSetUpdate`], which let an application
+//! collect input (PIN entry or a presence confirmation) and feed it back into
+//! the ongoing operation. The [`transport`] module manages device discovery and
+//! communication, and the [`pin`] module handles the cryptographic side of PIN
+//! and user verification.
+//!
+//! # Getting started
+//!
+//! A typical flow is to enumerate devices on your transport of choice, open a
+//! [`Channel`](transport::Channel) to each one, and run a ceremony on that
+//! channel. The ceremony traits are blanket-implemented for every channel:
+//! [`WebAuthn`](webauthn::WebAuthn) for make-credential and get-assertion, and
+//! the [`management`] traits ([`CredentialManagement`](management::CredentialManagement),
+//! [`AuthenticatorConfig`](management::AuthenticatorConfig),
+//! [`BioEnrollment`](management::BioEnrollment)) for the administrative
+//! ceremonies. The make-credential and get-assertion requests are built from
+//! their WebAuthn IDL (the JSON the browser API speaks) with
+//! [`MakeCredentialRequest::prepare`](ops::webauthn::MakeCredentialRequest::prepare)
+//! and [`GetAssertionRequest::prepare`](ops::webauthn::GetAssertionRequest::prepare),
+//! which validate the caller origin against the relying party ID.
+//!
+//! ```no_run
+//! use libwebauthn::ops::webauthn::{
+//! MakeCredentialRequest, OriginValidation, RelatedOrigins, RequestOrigin, RequestSettings,
+//! SystemPublicSuffixList,
+//! };
+//! use libwebauthn::transport::hid::list_devices;
+//! use libwebauthn::transport::Device;
+//! use libwebauthn::webauthn::WebAuthn;
+//!
+//! # async fn run() -> Result<(), Box> {
+//! // 1. Enumerate authenticators on your transport of choice (HID shown here).
+//! let devices = list_devices().await?;
+//!
+//! for mut device in devices {
+//! // 2. Open a channel to the device.
+//! let mut channel = device.channel().await?;
+//!
+//! // 3. Build a request from its WebAuthn IDL JSON.
+//! let origin: RequestOrigin = "https://example.org".try_into().expect("invalid origin");
+//! let psl = SystemPublicSuffixList::auto().expect("public suffix list unavailable");
+//! let settings = RequestSettings {
+//! origin: OriginValidation::Validate {
+//! public_suffix_list: &psl,
+//! related_origins: RelatedOrigins::Disabled,
+//! },
+//! };
+//! let request_json = r#"{ "rp": { "id": "example.org", "name": "Example" } }"#; // abbreviated
+//! let request =
+//! MakeCredentialRequest::prepare(&origin, request_json, &settings).await?;
+//!
+//! // 4. Run the ceremony on the channel.
+//! let _response = channel.webauthn_make_credential(&request).await?;
+//! }
+//! # Ok(())
+//! # }
+//! ```
+
// Production code must not panic. Tests keep unwrap/expect/panic latitude
// through `not(test)`, and the virt test-utility code through local allows.
#![cfg_attr(not(test), deny(clippy::unwrap_used))]
diff --git a/libwebauthn/src/management.rs b/libwebauthn/src/management.rs
index 8ff3f9da..9534ef3f 100644
--- a/libwebauthn/src/management.rs
+++ b/libwebauthn/src/management.rs
@@ -1,3 +1,16 @@
+//! Administration interfaces for CTAP2 authenticators. This module exposes
+//! traits for managing device configuration, biometric enrollment, and stored
+//! credentials over any [`Channel`](crate::transport::Channel) transport. These
+//! operations require user verification through a PIN or biometric factor, and
+//! the protocol handles token acquisition and retry internally.
+//!
+//! Use [`CredentialManagement`] to enumerate and delete resident credentials,
+//! [`AuthenticatorConfig`] to adjust device settings such as PIN policy and
+//! enterprise attestation, and [`BioEnrollment`] to manage biometric templates.
+//! Each trait is blanket-implemented for any
+//! [`Channel`](crate::transport::Channel), so the same API works across every
+//! transport.
+
mod bio_enrollment;
pub use bio_enrollment::BioEnrollment;
diff --git a/libwebauthn/src/ops/mod.rs b/libwebauthn/src/ops/mod.rs
index 73931b52..df610d27 100644
--- a/libwebauthn/src/ops/mod.rs
+++ b/libwebauthn/src/ops/mod.rs
@@ -1,2 +1,25 @@
+//! Request and response types for WebAuthn registration and authentication
+//! ceremonies, alongside the legacy FIDO U2F operation types. The main types are
+//! [`MakeCredentialRequest`](webauthn::MakeCredentialRequest) and
+//! [`GetAssertionRequest`](webauthn::GetAssertionRequest), which describe the
+//! parameters for creating and asserting a credential, and the matching
+//! [`MakeCredentialResponse`](webauthn::MakeCredentialResponse) and
+//! [`GetAssertionResponse`](webauthn::GetAssertionResponse) that carry the
+//! outcome. The request types also cover WebAuthn extensions such as PRF,
+//! HMAC secret, credProtect, and large blob storage.
+//!
+//! Always build these requests from their WebAuthn IDL JSON with
+//! [`MakeCredentialRequest::prepare`](webauthn::MakeCredentialRequest::prepare)
+//! and [`GetAssertionRequest::prepare`](webauthn::GetAssertionRequest::prepare):
+//! `prepare` validates the caller origin against the relying party ID, so it is
+//! the only safe way to construct an operation.
+//!
+//! The module also defines the operation kinds and user verification
+//! requirements, client data hashing helpers, and conversions between the
+//! WebAuthn IDL (JSON) representation and the internal types. A U2F response can
+//! be promoted to its WebAuthn equivalent through the
+//! [`UpgradableResponse`](u2f::UpgradableResponse) trait, which bridges legacy
+//! credentials into the WebAuthn world.
+
pub mod u2f;
pub mod webauthn;
diff --git a/libwebauthn/src/ops/webauthn/get_assertion.rs b/libwebauthn/src/ops/webauthn/get_assertion.rs
index 1fcbf7d0..77b1bd9b 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};
@@ -13,16 +14,14 @@ use crate::{
HmacGetSecretInputJson, LargeBlobInputJson, PrfInputJson,
PublicKeyCredentialRequestOptionsJSON,
},
- origin::is_registrable_domain_suffix_or_equal,
response::{
AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON,
AuthenticatorAssertionResponseJSON, HMACGetSecretOutputJSON, LargeBlobOutputJSON,
PRFOutputJSON, PRFValuesJSON, ResponseSerializationError, WebAuthnIDLResponse,
},
- Base64UrlString, FromIdlModel, JsonError,
+ rp_id_authorised, Base64UrlString, FromIdlModel, JsonError, RequestSettings,
},
- psl::PublicSuffixList,
- Operation, WebAuthnIDL,
+ Operation,
},
pin::PinUvAuthProtocol,
proto::ctap2::{
@@ -109,7 +108,7 @@ impl GetAssertionRequest {
}
#[derive(thiserror::Error, Debug)]
-pub enum GetAssertionRequestParsingError {
+pub enum GetAssertionPrepareError {
/// The client must throw an "EncodingError" DOMException.
#[error("Invalid JSON format: {0}")]
EncodingError(#[from] JsonError),
@@ -135,24 +134,32 @@ pub enum GetAssertionRequestParsingError {
/// Per spec the AppID should be a same-site URL (typically `https:///...`).
/// Full same-site validation against the rpId is not yet implemented; for now
/// we require non-empty input, an absolute URL form, and the `https` scheme.
-fn validate_appid(appid: &str) -> Result {
+fn validate_appid(appid: &str) -> Result {
if appid.is_empty() {
- return Err(GetAssertionRequestParsingError::InvalidAppId(
+ return Err(GetAssertionPrepareError::InvalidAppId(
"appid must not be empty".to_string(),
));
}
// Sanity check: must be an https URL.
if !appid.starts_with("https://") {
- return Err(GetAssertionRequestParsingError::InvalidAppId(format!(
+ return Err(GetAssertionPrepareError::InvalidAppId(format!(
"appid must be an https URL, got: {appid}"
)));
}
Ok(appid.to_string())
}
-impl WebAuthnIDL for GetAssertionRequest {
- type Error = GetAssertionRequestParsingError;
- type IdlModel = PublicKeyCredentialRequestOptionsJSON;
+impl GetAssertionRequest {
+ /// Builds a [`GetAssertionRequest`] from its WebAuthn IDL JSON, validating
+ /// the caller origin against rp.id per `settings`.
+ pub async fn prepare(
+ request_origin: &RequestOrigin,
+ json: &str,
+ settings: &RequestSettings<'_>,
+ ) -> Result {
+ let model: PublicKeyCredentialRequestOptionsJSON = serde_json::from_str(json)?;
+ Self::from_idl_model(request_origin, settings, model).await
+ }
}
/** dictionary PublicKeyCredentialRequestOptionsJSON {
@@ -164,22 +171,21 @@ impl WebAuthnIDL for GetAssertionRequest {
sequence hints = [];
AuthenticationExtensionsClientInputsJSON extensions;
}; */
-impl FromIdlModel
- for GetAssertionRequest
-{
- fn from_idl_model(
+#[async_trait]
+impl FromIdlModel for GetAssertionRequest {
+ type Error = GetAssertionPrepareError;
+
+ async fn from_idl_model(
request_origin: &RequestOrigin,
- psl: &dyn PublicSuffixList,
+ settings: &RequestSettings<'_>,
inner: PublicKeyCredentialRequestOptionsJSON,
- ) -> Result {
+ ) -> Result {
let effective_rp_id = request_origin.origin.host.as_str();
let resolved_rp_id = if let Some(relying_party_id) = inner.relying_party_id.as_deref() {
- let parsed = RelyingPartyId::try_from(relying_party_id).map_err(|err| {
- GetAssertionRequestParsingError::InvalidRelyingPartyId(err.to_string())
- })?;
- // TODO(#160): Add related-origins fallback per WebAuthn L3 §5.11.
- if !is_registrable_domain_suffix_or_equal(&parsed.0, effective_rp_id, psl) {
- return Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
+ let parsed = RelyingPartyId::try_from(relying_party_id)
+ .map_err(|err| GetAssertionPrepareError::InvalidRelyingPartyId(err.to_string()))?;
+ if !rp_id_authorised(request_origin, &parsed, settings).await {
+ return Err(GetAssertionPrepareError::MismatchingRelyingPartyId(
parsed.0,
effective_rp_id.to_string(),
));
@@ -254,7 +260,7 @@ pub struct PrfInput {
}
impl TryFrom for PrfInput {
- type Error = GetAssertionRequestParsingError;
+ type Error = GetAssertionPrepareError;
fn try_from(value: PrfInputJson) -> Result {
let eval = value.eval.map(|v| PrfInputValue {
@@ -298,18 +304,18 @@ pub struct HMACGetSecretInput {
}
impl TryFrom for HMACGetSecretInput {
- type Error = GetAssertionRequestParsingError;
+ type Error = GetAssertionPrepareError;
fn try_from(value: HmacGetSecretInputJson) -> Result {
let salt1 = value.salt1.as_slice().try_into().map_err(|_| {
- GetAssertionRequestParsingError::UnexpectedLengthError(
+ GetAssertionPrepareError::UnexpectedLengthError(
"extensions.hmacCreateSecret.salt1".to_string(),
value.salt1.as_slice().len(),
)
})?;
let salt2 = match value.salt2 {
Some(s) => Some(s.as_slice().try_into().map_err(|_| {
- GetAssertionRequestParsingError::UnexpectedLengthError(
+ GetAssertionPrepareError::UnexpectedLengthError(
"extensions.hmacCreateSecret.salt2".to_string(),
s.as_slice().len(),
)
@@ -328,15 +334,15 @@ pub enum GetAssertionLargeBlobExtension {
}
impl TryFrom for GetAssertionLargeBlobExtension {
- type Error = GetAssertionRequestParsingError;
+ type Error = GetAssertionPrepareError;
fn try_from(value: LargeBlobInputJson) -> Result {
match value.read {
Some(true) => Ok(GetAssertionLargeBlobExtension::Read),
- Some(false) => Err(GetAssertionRequestParsingError::NotSupported(
+ Some(false) => Err(GetAssertionPrepareError::NotSupported(
"largeBlob writes not supported".to_string(),
)),
- None => Err(GetAssertionRequestParsingError::NotSupported(
+ None => Err(GetAssertionPrepareError::NotSupported(
"largeBlob read not requested".to_string(),
)),
}
@@ -649,14 +655,75 @@ impl DowngradableRequest> 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::psl::{MockPublicSuffixList, PublicSuffixList};
+ use crate::ops::webauthn::related_origins::{
+ HttpClientError, MaxRegistrableLabels, RelatedOrigins, RelatedOriginsError,
+ RelatedOriginsSource,
+ };
+ use crate::ops::webauthn::{GetAssertionRequest, OriginValidation, RequestOrigin};
use crate::proto::ctap2::Ctap2PublicKeyCredentialType;
use super::*;
+ // Fixed-result source; `panicking` proves the suffix-check short-circuit by
+ // failing if consulted.
+ struct MockSource {
+ result: Option, RelatedOriginsError>>,
+ }
+
+ impl MockSource {
+ fn origins(items: &[&str]) -> Self {
+ Self {
+ result: Some(Ok(items.iter().map(|s| s.to_string()).collect())),
+ }
+ }
+
+ fn err(e: RelatedOriginsError) -> Self {
+ Self {
+ result: Some(Err(e)),
+ }
+ }
+
+ fn panicking() -> Self {
+ Self { result: None }
+ }
+ }
+
+ #[async_trait]
+ impl RelatedOriginsSource for MockSource {
+ async fn allowed_origins(
+ &self,
+ _: &RelyingPartyId,
+ ) -> Result, RelatedOriginsError> {
+ match &self.result {
+ Some(r) => r.clone(),
+ None => panic!("allowed_origins should not be called"),
+ }
+ }
+ }
+
+ async fn from_json(
+ origin: &RequestOrigin,
+ psl: &dyn PublicSuffixList,
+ related_origins: RelatedOrigins<'_>,
+ json: &str,
+ ) -> Result {
+ GetAssertionRequest::prepare(
+ origin,
+ json,
+ &RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: psl,
+ related_origins,
+ },
+ },
+ )
+ .await
+ }
+
pub const REQUEST_BASE_JSON: &str = r#"
{
"challenge": "Y3JlZGVudGlhbHMtZm9yLWxpbnV4L2xpYndlYmF1dGhu",
@@ -703,95 +770,227 @@ 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(
+ let req: GetAssertionRequest = from_json(
&request_origin,
&MockPublicSuffixList,
+ RelatedOrigins::Disabled,
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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
- Err(GetAssertionRequestParsingError::InvalidRelyingPartyId(_))
+ Err(GetAssertionPrepareError::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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
- Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
- _,
- _
- ))
+ Err(GetAssertionPrepareError::MismatchingRelyingPartyId(_, _))
));
}
- #[test]
- fn test_request_from_json_rp_id_is_parent_registrable_suffix() {
+ #[tokio::test]
+ async fn origin_trust_accepts_mismatching_rp_id() {
+ let request_origin: RequestOrigin = "https://app.example.org".parse().unwrap();
+ let req_json = json_field_add(REQUEST_BASE_JSON, "rpId", r#""example.com""#);
+ let req = GetAssertionRequest::prepare(
+ &request_origin,
+ &req_json,
+ &RequestSettings {
+ origin: OriginValidation::Trust,
+ },
+ )
+ .await
+ .unwrap();
+ assert_eq!(req.relying_party_id, "example.com");
+ }
+
+ #[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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
- Err(GetAssertionRequestParsingError::MismatchingRelyingPartyId(
- _,
- _
- ))
+ Err(GetAssertionPrepareError::MismatchingRelyingPartyId(_, _))
));
}
- #[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 source = MockSource::origins(&["https://app.example.org"]);
+
+ let req = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Enabled {
+ source: &source,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ &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 source = MockSource::origins(&["https://other.org"]);
+
+ let result = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Enabled {
+ source: &source,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ &req_json,
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(GetAssertionPrepareError::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 source = MockSource::err(RelatedOriginsError::Http(HttpClientError::Transport(
+ "simulated".into(),
+ )));
+
+ let result = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Enabled {
+ source: &source,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ &req_json,
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(GetAssertionPrepareError::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 source = MockSource::panicking();
+
+ let req = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Enabled {
+ source: &source,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ &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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(
req,
GetAssertionRequest {
@@ -801,36 +1000,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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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,
@@ -838,9 +1047,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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await
+ .unwrap();
let ext = req.extensions.expect("extensions should be present");
assert_eq!(
ext.appid.as_deref(),
@@ -848,8 +1062,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,
@@ -857,10 +1071,16 @@ mod tests {
r#"{"appid":"http://www.example.org/u2f/origins.json"}"#,
);
- let res = GetAssertionRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let res = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await;
assert!(matches!(
res,
- Err(GetAssertionRequestParsingError::InvalidAppId(_))
+ Err(GetAssertionPrepareError::InvalidAppId(_))
));
}
@@ -909,34 +1129,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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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}");
@@ -944,34 +1170,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..b9625e63 100644
--- a/libwebauthn/src/ops/webauthn/idl/mod.rs
+++ b/libwebauthn/src/ops/webauthn/idl/mod.rs
@@ -14,45 +14,92 @@ pub use response::{
WebAuthnIDLResponse,
};
-use origin::RequestOrigin;
+use async_trait::async_trait;
+use serde::de::DeserializeOwned;
+use tracing::debug;
-use super::psl::PublicSuffixList;
+use origin::{is_registrable_domain_suffix_or_equal, RequestOrigin};
+use rpid::RelyingPartyId;
-use serde::de::DeserializeOwned;
-use serde_json;
+use super::psl::PublicSuffixList;
+use super::related_origins::{validate_related_origins, RelatedOrigins};
pub type JsonError = serde_json::Error;
-pub trait WebAuthnIDL: Sized
-where
- E: std::error::Error, // Validation error type.
- 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;
-
- /// The JSON model that this IDL can deserialize from.
- type IdlModel: DeserializeOwned;
+/// Per-request settings (currently just the origin-validation policy).
+pub struct RequestSettings<'a> {
+ pub origin: OriginValidation<'a>,
+}
- fn from_json(
- request_origin: &RequestOrigin,
- psl: &dyn PublicSuffixList,
- 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)
- }
+/// How the caller origin is validated against the request rp.id.
+pub enum OriginValidation<'a> {
+ /// Trust the caller's origin to rp.id binding with no check, for callers
+ /// that have already validated it (e.g. a browser). Misuse defeats phishing
+ /// resistance, so the caller owns that decision.
+ Trust,
+ /// Validate rp.id against the caller origin: a registrable suffix of the
+ /// effective domain, then related origins on mismatch.
+ Validate {
+ public_suffix_list: &'a dyn PublicSuffixList,
+ related_origins: RelatedOrigins<'a>,
+ },
}
-pub trait FromIdlModel: Sized
+/// Builds a request from its parsed IDL model, validating origin against rp.id.
+#[async_trait]
+pub(crate) trait FromIdlModel: Sized
where
- T: DeserializeOwned,
- E: std::error::Error,
+ T: DeserializeOwned + Send,
{
- fn from_idl_model(
+ type Error: std::error::Error + From;
+
+ async fn from_idl_model(
request_origin: &RequestOrigin,
- psl: &dyn PublicSuffixList,
+ settings: &RequestSettings<'_>,
model: T,
- ) -> Result;
+ ) -> Result;
+}
+
+/// Whether `request_origin` may act for `rp_id`. `Trust` accepts any rp.id;
+/// `Validate` requires a registrable suffix of the caller's effective domain or
+/// a matching related origin.
+pub(crate) async fn rp_id_authorised(
+ request_origin: &RequestOrigin,
+ rp_id: &RelyingPartyId,
+ settings: &RequestSettings<'_>,
+) -> bool {
+ let (public_suffix_list, related_origins) = match &settings.origin {
+ OriginValidation::Trust => return true,
+ OriginValidation::Validate {
+ public_suffix_list,
+ related_origins,
+ } => (*public_suffix_list, related_origins),
+ };
+ let effective_rp_id = request_origin.origin.host.as_str();
+ if is_registrable_domain_suffix_or_equal(&rp_id.0, effective_rp_id, public_suffix_list) {
+ return true;
+ }
+ match related_origins {
+ RelatedOrigins::Disabled => false,
+ RelatedOrigins::Enabled { source, max_labels } => {
+ match source.allowed_origins(rp_id).await {
+ Err(err) => {
+ debug!(rp_id = %rp_id.0, %err, "Related-origins resolution failed");
+ false
+ }
+ Ok(origins) => match validate_related_origins(
+ &request_origin.origin,
+ &origins,
+ public_suffix_list,
+ *max_labels,
+ ) {
+ Ok(()) => true,
+ Err(err) => {
+ debug!(rp_id = %rp_id.0, %err, "Related-origins match failed");
+ false
+ }
+ },
+ }
+ }
+ }
}
diff --git a/libwebauthn/src/ops/webauthn/make_credential.rs b/libwebauthn/src/ops/webauthn/make_credential.rs
index 0601a9a6..ed8acb5c 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};
@@ -12,15 +13,13 @@ use crate::{
idl::{
create::PublicKeyCredentialCreationOptionsJSON,
get::PrfValuesJson,
- origin::is_registrable_domain_suffix_or_equal,
response::{
AuthenticationExtensionsClientOutputsJSON, AuthenticatorAttestationResponseJSON,
CredentialPropertiesOutputJSON, LargeBlobOutputJSON, PRFOutputJSON, PRFValuesJSON,
RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDLResponse,
},
- Base64UrlString, FromIdlModel, JsonError, WebAuthnIDL,
+ rp_id_authorised, Base64UrlString, FromIdlModel, JsonError, RequestSettings,
},
- psl::PublicSuffixList,
Operation, PrfInputValue, PrfOutputValue, RelyingPartyId, RequestOrigin,
},
proto::{
@@ -365,26 +364,23 @@ impl MakeCredentialRequest {
}
}
-impl FromIdlModel
- for MakeCredentialRequest
-{
- fn from_idl_model(
+#[async_trait]
+impl FromIdlModel for MakeCredentialRequest {
+ type Error = MakeCredentialPrepareError;
+
+ async fn from_idl_model(
request_origin: &RequestOrigin,
- psl: &dyn PublicSuffixList,
+ settings: &RequestSettings<'_>,
inner: PublicKeyCredentialCreationOptionsJSON,
- ) -> Result {
+ ) -> 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(),
- ),
- );
+ let rp_id = RelyingPartyId::try_from(inner.rp.id.as_str())
+ .map_err(|err| MakeCredentialPrepareError::InvalidRelyingPartyId(err.to_string()))?;
+ if !rp_id_authorised(request_origin, &rp_id, settings).await {
+ return Err(MakeCredentialPrepareError::MismatchingRelyingPartyId(
+ rp_id.0,
+ effective_rp_id.to_string(),
+ ));
}
let mut relying_party = inner.rp;
relying_party.id = rp_id.0;
@@ -435,7 +431,7 @@ impl FromIdlModel for MakeCredentialRequest {
- type Error = MakeCredentialRequestParsingError;
- type IdlModel = PublicKeyCredentialCreationOptionsJSON;
+impl MakeCredentialRequest {
+ /// Builds a [`MakeCredentialRequest`] from its WebAuthn IDL JSON, validating
+ /// the caller origin against rp.id per `settings`.
+ pub async fn prepare(
+ request_origin: &RequestOrigin,
+ json: &str,
+ settings: &RequestSettings<'_>,
+ ) -> Result {
+ let model: PublicKeyCredentialCreationOptionsJSON = serde_json::from_str(json)?;
+ Self::from_idl_model(request_origin, settings, model).await
+ }
}
#[derive(Debug, Default, Clone, Deserialize, PartialEq)]
@@ -691,12 +695,74 @@ impl DowngradableRequest for MakeCredentialRequest {
mod tests {
use std::time::Duration;
- use crate::ops::webauthn::psl::MockPublicSuffixList;
- use crate::ops::webauthn::{MakeCredentialRequest, RequestOrigin};
+ use async_trait::async_trait;
+
+ use crate::ops::webauthn::psl::{MockPublicSuffixList, PublicSuffixList};
+ use crate::ops::webauthn::related_origins::{
+ HttpClientError, MaxRegistrableLabels, RelatedOrigins, RelatedOriginsError,
+ RelatedOriginsSource,
+ };
+ use crate::ops::webauthn::{MakeCredentialRequest, OriginValidation, RequestOrigin};
use crate::proto::ctap2::Ctap2PublicKeyCredentialType;
use super::*;
+ // Fixed-result source; `panicking` proves the suffix-check short-circuit by
+ // failing if consulted.
+ struct MockSource {
+ result: Option, RelatedOriginsError>>,
+ }
+
+ impl MockSource {
+ fn origins(items: &[&str]) -> Self {
+ Self {
+ result: Some(Ok(items.iter().map(|s| s.to_string()).collect())),
+ }
+ }
+
+ fn err(e: RelatedOriginsError) -> Self {
+ Self {
+ result: Some(Err(e)),
+ }
+ }
+
+ fn panicking() -> Self {
+ Self { result: None }
+ }
+ }
+
+ #[async_trait]
+ impl RelatedOriginsSource for MockSource {
+ async fn allowed_origins(
+ &self,
+ _: &RelyingPartyId,
+ ) -> Result, RelatedOriginsError> {
+ match &self.result {
+ Some(r) => r.clone(),
+ None => panic!("allowed_origins should not be called"),
+ }
+ }
+ }
+
+ async fn from_json(
+ origin: &RequestOrigin,
+ psl: &dyn PublicSuffixList,
+ related_origins: RelatedOrigins<'_>,
+ json: &str,
+ ) -> Result {
+ MakeCredentialRequest::prepare(
+ origin,
+ json,
+ &RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: psl,
+ related_origins,
+ },
+ },
+ )
+ .await
+ }
+
pub const REQUEST_BASE_JSON: &str = r#"
{
"rp": {
@@ -756,76 +822,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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
- Err(MakeCredentialRequestParsingError::EncodingError(_))
+ Err(MakeCredentialPrepareError::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(
+ let req: MakeCredentialRequest = from_json(
&request_origin,
&MockPublicSuffixList,
+ RelatedOrigins::Disabled,
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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
- Err(MakeCredentialRequestParsingError::EncodingError(_))
+ Err(MakeCredentialPrepareError::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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await
+ .unwrap();
let prf = req
.extensions
.as_ref()
@@ -836,29 +919,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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await
+ .unwrap();
let prf = req
.extensions
.as_ref()
@@ -869,8 +962,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,
@@ -878,9 +971,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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await
+ .unwrap();
let ext = req.extensions.expect("extensions should be present");
assert_eq!(
ext.appid_exclude.as_deref(),
@@ -888,17 +986,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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(
req.algorithms,
vec![Ctap2CredentialType {
@@ -908,27 +1011,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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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 = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await
+ .unwrap();
assert_eq!(
req.user_verification,
UserVerificationRequirement::Preferred
@@ -937,8 +1050,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");
@@ -948,17 +1061,22 @@ mod tests {
r#"{"residentKey": "discouraged"}"#,
);
- let req: MakeCredentialRequest =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req: MakeCredentialRequest = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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,
@@ -966,16 +1084,63 @@ mod tests {
r#"{"id": "example.org.", "name": "example.org"}"#,
);
- let result =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
- Err(MakeCredentialRequestParsingError::InvalidRelyingPartyId(_))
+ Err(MakeCredentialPrepareError::InvalidRelyingPartyId(_))
));
}
- #[test]
- fn test_request_from_json_mismatching_rp_id() {
+ #[tokio::test]
+ async fn origin_trust_accepts_mismatching_rp_id() {
+ 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 req = MakeCredentialRequest::prepare(
+ &request_origin,
+ &req_json,
+ &RequestSettings {
+ origin: OriginValidation::Trust,
+ },
+ )
+ .await
+ .unwrap();
+ assert_eq!(req.relying_party.id, "example.com");
+ }
+
+ #[tokio::test]
+ async fn origin_trust_still_rejects_invalid_rp_id() {
+ let request_origin: RequestOrigin = "https://example.org".parse().unwrap();
+ let req_json = json_field_add(
+ REQUEST_BASE_JSON,
+ "rp",
+ r#"{"id": "example.org.", "name": "example.org"}"#,
+ );
+ let result = MakeCredentialRequest::prepare(
+ &request_origin,
+ &req_json,
+ &RequestSettings {
+ origin: OriginValidation::Trust,
+ },
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(MakeCredentialPrepareError::InvalidRelyingPartyId(_))
+ ));
+ }
+
+ #[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,
@@ -983,16 +1148,21 @@ mod tests {
r#"{"id": "other.example.org", "name": "example.org"}"#,
);
- let result =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
- Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _))
+ Err(MakeCredentialPrepareError::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,
@@ -1000,15 +1170,20 @@ mod tests {
r#"{"id": "example.org", "name": "example.org"}"#,
);
- let req =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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,
@@ -1016,16 +1191,21 @@ mod tests {
r#"{"id": "co.uk", "name": "co.uk"}"#,
);
- let result =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json);
+ let result = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &req_json,
+ )
+ .await;
assert!(matches!(
result,
- Err(MakeCredentialRequestParsingError::MismatchingRelyingPartyId(_, _))
+ Err(MakeCredentialPrepareError::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,
@@ -1033,15 +1213,20 @@ mod tests {
r#"{"id": "localhost", "name": "localhost"}"#,
);
- let req =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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,
@@ -1049,13 +1234,123 @@ mod tests {
r#"{"id": "localhost", "name": "localhost"}"#,
);
- let req =
- MakeCredentialRequest::from_json(&request_origin, &MockPublicSuffixList, &req_json)
- .unwrap();
+ let req = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Disabled,
+ &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 source = MockSource::origins(&["https://app.example.org"]);
+
+ let req = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Enabled {
+ source: &source,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ &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 source = MockSource::origins(&["https://other.org"]);
+
+ let result = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Enabled {
+ source: &source,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ &req_json,
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(MakeCredentialPrepareError::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 source = MockSource::err(RelatedOriginsError::Http(HttpClientError::Transport(
+ "simulated".into(),
+ )));
+
+ let result = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Enabled {
+ source: &source,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ &req_json,
+ )
+ .await;
+ assert!(matches!(
+ result,
+ Err(MakeCredentialPrepareError::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 source = MockSource::panicking();
+
+ let req = from_json(
+ &request_origin,
+ &MockPublicSuffixList,
+ RelatedOrigins::Enabled {
+ source: &source,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ &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..0caa0bc6 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};
@@ -10,32 +11,39 @@ use crate::webauthn::CtapError;
pub use client_data::ClientData;
pub use get_assertion::{
Assertion, Ctap2HMACGetSecretOutput, GetAssertionHmacOrPrfInput,
- GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrfOutput,
- GetAssertionRequest, GetAssertionRequestExtensions, GetAssertionResponse,
- GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions, HMACGetSecretInput,
- HMACGetSecretOutput, PrfInput, PrfInputValue, PrfOutputValue,
+ GetAssertionLargeBlobExtension, GetAssertionLargeBlobExtensionOutput, GetAssertionPrepareError,
+ GetAssertionPrfOutput, GetAssertionRequest, GetAssertionRequestExtensions,
+ GetAssertionResponse, GetAssertionResponseExtensions, GetAssertionResponseUnsignedExtensions,
+ HMACGetSecretInput, HMACGetSecretOutput, PrfInput, PrfInputValue, PrfOutputValue,
};
pub use idl::{
origin::{HostParseError, Origin, OriginHost, OriginParseError, RequestOrigin, Scheme},
rpid::RelyingPartyId,
AuthenticationExtensionsClientOutputsJSON, AuthenticationResponseJSON,
AuthenticatorAssertionResponseJSON, AuthenticatorAttestationResponseJSON, Base64UrlString,
- JsonFormat, RegistrationResponseJSON, ResponseSerializationError, WebAuthnIDL,
- WebAuthnIDLResponse,
+ JsonFormat, OriginValidation, RegistrationResponseJSON, RequestSettings,
+ ResponseSerializationError, WebAuthnIDLResponse,
};
pub use make_credential::{
CredentialPropsExtension, CredentialProtectionExtension, CredentialProtectionPolicy,
MakeCredentialLargeBlobExtension, MakeCredentialLargeBlobExtensionInput,
- MakeCredentialLargeBlobExtensionOutput, MakeCredentialPrfInput, MakeCredentialPrfOutput,
- MakeCredentialRequest, MakeCredentialResponse, MakeCredentialsRequestExtensions,
- MakeCredentialsResponseExtensions, MakeCredentialsResponseUnsignedExtensions,
- ResidentKeyRequirement,
+ MakeCredentialLargeBlobExtensionOutput, MakeCredentialPrepareError, MakeCredentialPrfInput,
+ MakeCredentialPrfOutput, MakeCredentialRequest, MakeCredentialResponse,
+ MakeCredentialsRequestExtensions, MakeCredentialsResponseExtensions,
+ MakeCredentialsResponseUnsignedExtensions, ResidentKeyRequirement,
};
pub use psl::{
DafsaFileLoadError, DafsaFilePublicSuffixList, DatFileLoadError, DatFilePublicSuffixList,
PublicSuffixList, SystemLoadError, SystemPublicSuffixList, SYSTEM_PSL_DAFSA_PATH,
SYSTEM_PSL_PATH,
};
+pub use related_origins::{
+ validate_related_origins, HttpClient, HttpClientError, MaxRegistrableLabels, RelatedOrigins,
+ RelatedOriginsError, RelatedOriginsSource, StaticRelatedOriginsSource,
+ WellKnownRelatedOriginsSource,
+};
+#[cfg(feature = "reqwest-related-origins-source")]
+pub use related_origins::{HttpPolicy, ReqwestHttpClient, ReqwestRelatedOriginsSource};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, Deserialize, PartialEq)]
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..2d2ab1e6
--- /dev/null
+++ b/libwebauthn/src/ops/webauthn/related_origins/mod.rs
@@ -0,0 +1,750 @@
+//! Related-origins validation (WebAuthn L3 §5.11). Matching lives here in the
+//! core. The allowed origins for an rp.id come from a [`RelatedOriginsSource`],
+//! the default [`WellKnownRelatedOriginsSource`] fetching the well-known
+//! document over an [`HttpClient`].
+
+use std::collections::{BTreeSet, HashMap};
+
+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 = "reqwest-related-origins-source")]
+mod reqwest_impl;
+
+#[cfg(feature = "reqwest-related-origins-source")]
+pub use reqwest_impl::{HttpPolicy, ReqwestHttpClient, ReqwestRelatedOriginsSource};
+
+/// Cap on distinct registrable-domain labels considered during matching
+/// (WebAuthn L3 §5.11). Cannot hold a value below the spec floor of five.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct MaxRegistrableLabels(usize);
+
+impl MaxRegistrableLabels {
+ /// Absolute cap, or `None` if below the floor of five.
+ pub const fn new(n: usize) -> Option {
+ if n >= 5 {
+ Some(Self(n))
+ } else {
+ None
+ }
+ }
+
+ /// `n` labels beyond the floor of five.
+ pub const fn extra(n: usize) -> Self {
+ Self(5usize.saturating_add(n))
+ }
+}
+
+impl Default for MaxRegistrableLabels {
+ fn default() -> Self {
+ Self(5)
+ }
+}
+
+impl From for usize {
+ fn from(value: MaxRegistrableLabels) -> Self {
+ value.0
+ }
+}
+
+/// Transport failure modes for [`HttpClient::get`].
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum HttpClientError {
+ /// TLS, DNS, timeout, rejected redirect, stream interrupt, client build, etc.
+ #[error("transport error: {0}")]
+ Transport(String),
+ /// Body exceeded the implementation's configured size cap before completion.
+ #[error("response body exceeded the configured size cap")]
+ BodyTooLarge,
+}
+
+/// Minimal HTTP GET for fetching the well-known document. Implementations MUST
+/// send no credentials or Referer, follow only `https://` redirects, and cap the
+/// body size and duration. Status is returned as data, the body unparsed.
+#[async_trait]
+pub trait HttpClient: Send + Sync {
+ async fn get(&self, url: &Url) -> Result>, HttpClientError>;
+}
+
+/// Supplies the set of allowed origins for an RP id (WebAuthn L3 §5.11).
+/// [`WellKnownRelatedOriginsSource`] is the default, fetching the well-known
+/// document; a caller that already holds the list may implement this directly.
+#[async_trait]
+pub trait RelatedOriginsSource: Send + Sync {
+ async fn allowed_origins(
+ &self,
+ rp_id: &RelyingPartyId,
+ ) -> Result, RelatedOriginsError>;
+}
+
+/// How related-origins is handled for a request.
+pub enum RelatedOrigins<'a> {
+ /// Do not consult related origins; a mismatching rp.id is rejected.
+ Disabled,
+ /// Resolve allowed origins through `source` and match, considering at most
+ /// `max_labels` distinct registrable-domain labels.
+ Enabled {
+ source: &'a dyn RelatedOriginsSource,
+ max_labels: MaxRegistrableLabels,
+ },
+}
+
+/// Failure modes when resolving or matching related origins.
+#[derive(thiserror::Error, Debug, Clone)]
+pub enum RelatedOriginsError {
+ #[error("http error: {0}")]
+ Http(#[from] HttpClientError),
+ /// Endpoint replied with a non-200 status (after following redirects).
+ #[error("unexpected HTTP status {0}")]
+ UnexpectedStatus(u16),
+ #[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,
+}
+
+/// [`RelatedOriginsSource`] that fetches `https://{rp_id}/.well-known/webauthn`
+/// over an [`HttpClient`] and returns its `origins` array (WebAuthn L3 §5.11.1
+/// step 2). Generic over the transport.
+pub struct WellKnownRelatedOriginsSource {
+ http: C,
+}
+
+impl WellKnownRelatedOriginsSource {
+ /// Wrap an [`HttpClient`] as a well-known related-origins source.
+ pub fn from_client(http: C) -> Self {
+ Self { http }
+ }
+}
+
+#[async_trait]
+impl RelatedOriginsSource for WellKnownRelatedOriginsSource {
+ async fn allowed_origins(
+ &self,
+ rp_id: &RelyingPartyId,
+ ) -> Result, RelatedOriginsError> {
+ let url = Url::parse(&format!("https://{}/.well-known/webauthn", rp_id.0))
+ .map_err(|e| RelatedOriginsError::MalformedDocument(format!("invalid rp id: {e}")))?;
+ let resp = self.http.get(&url).await?;
+ if resp.status() != http::StatusCode::OK {
+ return Err(RelatedOriginsError::UnexpectedStatus(
+ resp.status().as_u16(),
+ ));
+ }
+ let content_type = resp
+ .headers()
+ .get(http::header::CONTENT_TYPE)
+ .and_then(|v| v.to_str().ok())
+ .map(str::to_owned);
+ let content_type_ok = content_type
+ .as_deref()
+ .map(is_application_json)
+ .unwrap_or(false);
+ if !content_type_ok {
+ return Err(RelatedOriginsError::UnexpectedContentType(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()))?;
+ Ok(doc.origins)
+ }
+}
+
+/// [`RelatedOriginsSource`] backed by an in-memory map of rp.id to its known
+/// related origins, for callers that already hold the list and want no fetch.
+pub struct StaticRelatedOriginsSource {
+ by_rp_id: HashMap>,
+}
+
+impl StaticRelatedOriginsSource {
+ /// Source for a single rp.id and its known related origins.
+ pub fn new(
+ rp_id: impl Into,
+ origins: impl IntoIterator- >,
+ ) -> Self {
+ let mut by_rp_id = HashMap::new();
+ by_rp_id.insert(rp_id.into(), origins.into_iter().map(Into::into).collect());
+ Self { by_rp_id }
+ }
+
+ /// Source backed by a map of rp.id to its known related origins.
+ pub fn from_map(by_rp_id: HashMap>) -> Self {
+ Self { by_rp_id }
+ }
+}
+
+#[async_trait]
+impl RelatedOriginsSource for StaticRelatedOriginsSource {
+ async fn allowed_origins(
+ &self,
+ rp_id: &RelyingPartyId,
+ ) -> Result, RelatedOriginsError> {
+ Ok(self
+ .by_rp_id
+ .get(rp_id.0.as_str())
+ .cloned()
+ .unwrap_or_default())
+ }
+}
+
+/// Runs the WebAuthn L3 §5.11.1 matching procedure: returns `Ok(())` if
+/// `caller_origin` is same-origin with one of `origins`, considering at most
+/// `max_labels` distinct registrable-domain labels.
+pub fn validate_related_origins(
+ caller_origin: &Origin,
+ origins: &[String],
+ psl: &dyn PublicSuffixList,
+ max_labels: MaxRegistrableLabels,
+) -> RelatedOriginsResult {
+ let cap: usize = max_labels.into();
+ let mut labels_seen: BTreeSet = BTreeSet::new();
+ for origin_item in 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() >= cap && !labels_seen.contains(&label) {
+ continue;
+ }
+ if same_origin(caller_origin, &url) {
+ return Ok(());
+ }
+ if labels_seen.len() < cap {
+ 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, or `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 (scheme, host, port) equality per WebAuthn L3 §5.11.1 step 4.f.
+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")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::psl::MockPublicSuffixList;
+ use super::*;
+
+ fn caller(s: &str) -> Origin {
+ Origin::try_from(s).unwrap()
+ }
+
+ fn rp(s: &str) -> RelyingPartyId {
+ RelyingPartyId::try_from(s).unwrap()
+ }
+
+ fn origins(items: &[&str]) -> Vec {
+ items.iter().map(|s| s.to_string()).collect()
+ }
+
+ /// Matches a caller origin against `items` with the default cap (5).
+ fn validate(caller_s: &str, items: &[&str]) -> RelatedOriginsResult {
+ validate_related_origins(
+ &caller(caller_s),
+ &origins(items),
+ &MockPublicSuffixList,
+ MaxRegistrableLabels::default(),
+ )
+ }
+
+ // ---- MaxRegistrableLabels ----
+
+ #[test]
+ fn max_registrable_labels_enforces_floor() {
+ assert_eq!(MaxRegistrableLabels::new(4), None);
+ assert_eq!(MaxRegistrableLabels::new(0), None);
+ assert_eq!(usize::from(MaxRegistrableLabels::new(5).unwrap()), 5);
+ assert_eq!(usize::from(MaxRegistrableLabels::new(50).unwrap()), 50);
+ assert_eq!(usize::from(MaxRegistrableLabels::extra(0)), 5);
+ assert_eq!(usize::from(MaxRegistrableLabels::extra(3)), 8);
+ assert_eq!(usize::from(MaxRegistrableLabels::default()), 5);
+ }
+
+ // ---- registrable_origin_label ----
+
+ #[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);
+ }
+
+ // ---- matching (validate_related_origins) ----
+
+ #[test]
+ fn same_origin_caller_listed_first() {
+ assert!(matches!(
+ validate("https://example.com", &["https://example.com"]),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn same_origin_with_port_match() {
+ assert!(matches!(
+ validate("https://example.com:8443", &["https://example.com:8443"]),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn same_origin_with_port_mismatch_rejected() {
+ assert!(matches!(
+ validate("https://example.com", &["https://example.com:8443"]),
+ Err(RelatedOriginsError::NoMatchingOrigin)
+ ));
+ }
+
+ #[test]
+ fn same_origin_default_port_normalised() {
+ assert!(matches!(
+ validate("https://example.com", &["https://example.com:443"]),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn caller_listed_after_other_origins() {
+ // Substituted `.de` with `.net` (MockPublicSuffixList lacks `.de`).
+ assert!(matches!(
+ validate(
+ "https://example.com",
+ &["https://other.net", "https://example.com"]
+ ),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn label_cap_allows_fifth_distinct_label_match() {
+ // The 5th distinct label is still within the cap.
+ assert!(matches!(
+ validate(
+ "https://example.com",
+ &[
+ "https://a.com",
+ "https://b.com",
+ "https://c.com",
+ "https://d.com",
+ "https://example.com",
+ ]
+ ),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn label_cap_blocks_sixth_distinct_label_match() {
+ // A sixth distinct label is silently skipped, so the would-be match
+ // never reaches the same-origin check.
+ assert!(matches!(
+ validate(
+ "https://example.com",
+ &[
+ "https://a.com",
+ "https://b.com",
+ "https://c.com",
+ "https://d.com",
+ "https://e.com",
+ "https://example.com",
+ ]
+ ),
+ Err(RelatedOriginsError::NoMatchingOrigin)
+ ));
+ }
+
+ #[test]
+ fn higher_cap_allows_sixth_distinct_label_match() {
+ // With a cap of 6 the previously-blocked sixth label matches.
+ let res = validate_related_origins(
+ &caller("https://example.com"),
+ &origins(&[
+ "https://a.com",
+ "https://b.com",
+ "https://c.com",
+ "https://d.com",
+ "https://e.com",
+ "https://example.com",
+ ]),
+ &MockPublicSuffixList,
+ MaxRegistrableLabels::extra(1),
+ );
+ assert!(matches!(res, Ok(())));
+ }
+
+ #[test]
+ fn label_cap_allows_repeats_of_seen_label() {
+ // Once `example` has been recorded, further `example`-label origins
+ // still proceed to the same-origin check.
+ assert!(matches!(
+ validate(
+ "https://login.example.com",
+ &[
+ "https://a.example.com",
+ "https://b.example.com",
+ "https://c.example.com",
+ "https://d.example.com",
+ "https://e.example.com",
+ "https://login.example.com",
+ ]
+ ),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn https_caller_vs_http_listed_rejected() {
+ // Scheme differs, so the listed http origin is never same-origin.
+ assert!(matches!(
+ validate("https://example.com", &["http://example.com"]),
+ Err(RelatedOriginsError::NoMatchingOrigin)
+ ));
+ }
+
+ #[test]
+ fn unparseable_origin_item_skipped() {
+ assert!(matches!(
+ validate("https://example.com", &["not a url", "https://example.com"]),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn non_https_origin_item_skipped_not_rejected() {
+ assert!(matches!(
+ validate(
+ "https://example.com",
+ &["data:text/plain,foo", "https://example.com"]
+ ),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn unknown_suffix_origin_skipped() {
+ // `internal.localhost` has no registrable domain in MockPSL.
+ assert!(matches!(
+ validate(
+ "https://example.com",
+ &["https://internal.localhost", "https://example.com"]
+ ),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn bare_etld_origin_skipped() {
+ // Registrable origin label is None for `co.uk`.
+ assert!(matches!(
+ validate(
+ "https://example.co.uk",
+ &["https://co.uk", "https://example.co.uk"]
+ ),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn no_match_returns_no_matching_origin() {
+ assert!(matches!(
+ validate("https://example.com", &["https://elsewhere.com"]),
+ Err(RelatedOriginsError::NoMatchingOrigin)
+ ));
+ }
+
+ #[test]
+ fn empty_origins_no_match() {
+ assert!(matches!(
+ validate("https://example.com", &[]),
+ Err(RelatedOriginsError::NoMatchingOrigin)
+ ));
+ }
+
+ #[test]
+ fn listed_origin_with_path_still_matches() {
+ // Same-origin compares (scheme, host, port) only, so a trailing path on
+ // the listed entry must not block the match.
+ assert!(matches!(
+ validate("https://example.com", &["https://example.com/foo"]),
+ Ok(())
+ ));
+ }
+
+ #[test]
+ fn ipv6_listed_origin_skipped_no_registrable_label() {
+ // IPv6 host has no registrable label; the loop skips it.
+ assert!(matches!(
+ validate("https://[::1]", &["https://[::1]"]),
+ Err(RelatedOriginsError::NoMatchingOrigin)
+ ));
+ }
+
+ // ---- well-known source fetch/parse ----
+
+ struct MockHttpClient {
+ status: u16,
+ content_type: Option,
+ body: Vec,
+ }
+
+ #[async_trait]
+ impl HttpClient for MockHttpClient {
+ async fn get(&self, _url: &Url) -> Result>, HttpClientError> {
+ let mut builder = http::Response::builder().status(self.status);
+ if let Some(ct) = &self.content_type {
+ builder = builder.header(http::header::CONTENT_TYPE, ct);
+ }
+ Ok(builder.body(self.body.clone()).unwrap())
+ }
+ }
+
+ struct ErrHttpClient(HttpClientError);
+
+ #[async_trait]
+ impl HttpClient for ErrHttpClient {
+ async fn get(&self, _url: &Url) -> Result>, HttpClientError> {
+ Err(self.0.clone())
+ }
+ }
+
+ async fn fetch(
+ status: u16,
+ content_type: Option<&str>,
+ body: &str,
+ ) -> Result, RelatedOriginsError> {
+ let source = WellKnownRelatedOriginsSource::from_client(MockHttpClient {
+ status,
+ content_type: content_type.map(str::to_owned),
+ body: body.as_bytes().to_vec(),
+ });
+ source.allowed_origins(&rp("example.com")).await
+ }
+
+ #[tokio::test]
+ async fn well_known_returns_origins() {
+ let res = fetch(
+ 200,
+ Some("application/json"),
+ r#"{"origins":["https://example.com"]}"#,
+ )
+ .await;
+ assert_eq!(res.unwrap(), vec!["https://example.com".to_string()]);
+ }
+
+ #[tokio::test]
+ async fn non_200_status_rejected() {
+ assert!(matches!(
+ fetch(404, Some("application/json"), r#"{"origins":[]}"#).await,
+ Err(RelatedOriginsError::UnexpectedStatus(404))
+ ));
+ }
+
+ #[tokio::test]
+ async fn wrong_content_type_rejected() {
+ assert!(matches!(
+ fetch(200, Some("text/html"), r#"{"origins":[]}"#).await,
+ Err(RelatedOriginsError::UnexpectedContentType(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn missing_content_type_rejected() {
+ assert!(matches!(
+ fetch(200, None, r#"{"origins":[]}"#).await,
+ Err(RelatedOriginsError::UnexpectedContentType(None))
+ ));
+ }
+
+ #[tokio::test]
+ async fn content_type_with_charset_accepted() {
+ let res = fetch(
+ 200,
+ Some("application/json; charset=utf-8"),
+ r#"{"origins":["https://elsewhere.com"]}"#,
+ )
+ .await;
+ assert_eq!(res.unwrap(), vec!["https://elsewhere.com".to_string()]);
+ }
+
+ #[tokio::test]
+ async fn content_type_case_insensitive() {
+ let res = fetch(
+ 200,
+ Some("Application/JSON"),
+ r#"{"origins":["https://example.com"]}"#,
+ )
+ .await;
+ assert_eq!(res.unwrap(), vec!["https://example.com".to_string()]);
+ }
+
+ #[tokio::test]
+ async fn malformed_json_rejected() {
+ assert!(matches!(
+ fetch(200, Some("application/json"), "{not json}").await,
+ Err(RelatedOriginsError::MalformedJson(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn non_object_json_rejected() {
+ assert!(matches!(
+ fetch(200, Some("application/json"), "[1,2,3]").await,
+ Err(RelatedOriginsError::MalformedJson(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn missing_origins_key_rejected() {
+ assert!(matches!(
+ fetch(200, Some("application/json"), "{}").await,
+ Err(RelatedOriginsError::MalformedDocument(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn origins_not_array_rejected() {
+ assert!(matches!(
+ fetch(
+ 200,
+ Some("application/json"),
+ r#"{"origins":"https://example.com"}"#
+ )
+ .await,
+ Err(RelatedOriginsError::MalformedDocument(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn origins_array_of_non_strings_rejected() {
+ assert!(matches!(
+ fetch(200, Some("application/json"), r#"{"origins":[1,2,3]}"#).await,
+ Err(RelatedOriginsError::MalformedDocument(_))
+ ));
+ }
+
+ #[tokio::test]
+ async fn transport_error_propagates_as_http() {
+ let source = WellKnownRelatedOriginsSource::from_client(ErrHttpClient(
+ HttpClientError::Transport("simulated".into()),
+ ));
+ let res = source.allowed_origins(&rp("example.com")).await;
+ assert!(matches!(
+ res,
+ Err(RelatedOriginsError::Http(HttpClientError::Transport(_)))
+ ));
+ }
+
+ #[tokio::test]
+ async fn static_source_returns_listed_origins() {
+ let single = StaticRelatedOriginsSource::new("example.com", ["https://app.example.org"]);
+ assert_eq!(
+ single.allowed_origins(&rp("example.com")).await.unwrap(),
+ vec!["https://app.example.org".to_string()]
+ );
+ assert!(single
+ .allowed_origins(&rp("other.com"))
+ .await
+ .unwrap()
+ .is_empty());
+
+ let multi = StaticRelatedOriginsSource::from_map(
+ [("a.com".to_string(), vec!["https://x.org".to_string()])]
+ .into_iter()
+ .collect(),
+ );
+ assert_eq!(
+ multi.allowed_origins(&rp("a.com")).await.unwrap(),
+ vec!["https://x.org".to_string()]
+ );
+ }
+}
diff --git a/libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs b/libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs
new file mode 100644
index 00000000..e62bfff6
--- /dev/null
+++ b/libwebauthn/src/ops/webauthn/related_origins/reqwest_impl.rs
@@ -0,0 +1,118 @@
+//! reqwest-backed [`HttpClient`] and the convenience [`ReqwestRelatedOriginsSource`].
+//! Gated by the `reqwest-related-origins-source` cargo feature.
+
+use std::time::Duration;
+
+use async_trait::async_trait;
+use futures::StreamExt;
+use http::{HeaderMap, Response, StatusCode};
+use reqwest::redirect::Policy;
+use reqwest::Client;
+use url::Url;
+
+use super::{HttpClient, HttpClientError, WellKnownRelatedOriginsSource};
+
+#[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,
+ }
+ }
+}
+
+/// reqwest-backed [`HttpClient`]. Enforces https-only requests and redirects,
+/// sends no credentials or Referer, caps the body size, and bounds the request
+/// duration.
+#[derive(Debug, Clone)]
+pub struct ReqwestHttpClient {
+ client: Client,
+ max_body_bytes: usize,
+}
+
+impl ReqwestHttpClient {
+ 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: no referrer. The `cookies` feature is off,
+ // so reqwest sends no credentials.
+ let client = Client::builder()
+ .https_only(true)
+ .redirect(redirect_policy)
+ .referer(false)
+ .timeout(policy.request_timeout)
+ .build()
+ .map_err(|e| HttpClientError::Transport(e.to_string()))?;
+ Ok(Self {
+ client,
+ max_body_bytes: policy.max_body_bytes,
+ })
+ }
+}
+
+#[async_trait]
+impl HttpClient for ReqwestHttpClient {
+ async fn get(&self, url: &Url) -> Result>, HttpClientError> {
+ let response = self
+ .client
+ .get(url.clone())
+ .send()
+ .await
+ .map_err(|e| HttpClientError::Transport(e.to_string()))?;
+ let status: StatusCode = response.status();
+ let headers: HeaderMap = response.headers().clone();
+
+ 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| HttpClientError::Transport(e.to_string()))?;
+ if body.len() + chunk.len() > self.max_body_bytes {
+ return Err(HttpClientError::BodyTooLarge);
+ }
+ body.extend_from_slice(&chunk);
+ }
+
+ let mut out = Response::new(body);
+ *out.status_mut() = status;
+ *out.headers_mut() = headers;
+ Ok(out)
+ }
+}
+
+/// reqwest-backed [`RelatedOriginsSource`]: a [`WellKnownRelatedOriginsSource`]
+/// over a [`ReqwestHttpClient`].
+///
+/// [`RelatedOriginsSource`]: super::RelatedOriginsSource
+pub type ReqwestRelatedOriginsSource = WellKnownRelatedOriginsSource;
+
+impl WellKnownRelatedOriginsSource {
+ /// Build with the default [`HttpPolicy`].
+ pub fn new() -> Result {
+ Ok(Self::from_client(ReqwestHttpClient::new()?))
+ }
+
+ /// Build with a custom [`HttpPolicy`].
+ pub fn with_policy(policy: HttpPolicy) -> Result {
+ Ok(Self::from_client(ReqwestHttpClient::with_policy(policy)?))
+ }
+}
diff --git a/libwebauthn/src/pin.rs b/libwebauthn/src/pin.rs
index 3c022fa2..ffb5b86f 100644
--- a/libwebauthn/src/pin.rs
+++ b/libwebauthn/src/pin.rs
@@ -1,3 +1,18 @@
+//! PIN and user-verification interaction for CTAP2 authenticators. This module
+//! implements the PIN/UV authentication protocols (`PinUvAuthProtocolOne` and
+//! `PinUvAuthProtocolTwo`), which perform ECDH key agreement and AES-CBC
+//! encryption with HMAC authentication. These are the primitives used to derive
+//! a shared secret, encrypt a PIN, and produce the authentication parameters
+//! sent during registration and assertion.
+//!
+//! The public surface exposes [`PinRequestReason`] and [`PinNotSetReason`] so a
+//! caller can tell why a PIN is being requested or why setting one failed, and
+//! [`PinManagement`] for initiating PIN changes. In normal use a caller does not
+//! touch this module directly. PIN requests reach the application through the
+//! [`UvUpdate`](crate::UvUpdate) flow in the crate root, delivered as
+//! [`PinRequiredUpdate`](crate::PinRequiredUpdate) and
+//! [`PinNotSetUpdate`](crate::PinNotSetUpdate).
+
use std::time::Duration;
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut};
diff --git a/libwebauthn/src/proto/mod.rs b/libwebauthn/src/proto/mod.rs
index f7080d93..46506aa8 100644
--- a/libwebauthn/src/proto/mod.rs
+++ b/libwebauthn/src/proto/mod.rs
@@ -1,3 +1,16 @@
+//! The wire protocol layer for FIDO2/CTAP2 and FIDO U2F/CTAP1 authenticators.
+//! This module defines how device commands and responses are encoded and
+//! decoded: the CBOR request and response structures for CTAP2, the APDU frames
+//! for CTAP1, COSE keys, attestation statements, and the protocol status codes
+//! in [`CtapError`]. The [`Ctap1`](ctap1::Ctap1) and [`Ctap2`](ctap2::Ctap2)
+//! handlers drive these exchanges at the wire level.
+//!
+//! Alongside the core commands it covers CTAP2 preflight (checking which
+//! credentials a device holds before a ceremony), the PIN/UV authentication
+//! protocols, client PIN management, biometric enrollment, and on-device
+//! credential management. The encodings follow the FIDO Alliance specifications
+//! and account for the difference between APDU-based CTAP1 and CBOR-based CTAP2.
+
mod error;
pub mod ctap1;
diff --git a/libwebauthn/src/transport/mod.rs b/libwebauthn/src/transport/mod.rs
index 1e294b06..8f6c0306 100644
--- a/libwebauthn/src/transport/mod.rs
+++ b/libwebauthn/src/transport/mod.rs
@@ -1,3 +1,16 @@
+//! Transport layer that carries CTAP messages to FIDO2/WebAuthn authenticators.
+//! It abstracts over several physical media: HID (USB), BLE (Bluetooth Low
+//! Energy), caBLE (the cable/hybrid mode), and NFC (available when the
+//! `nfc-backend-pcsc` or `nfc-backend-libnfc` feature is enabled). The two core
+//! abstractions are [`Channel`], an open session with an authenticator that
+//! sends and receives CTAP messages, and [`Device`], a discovered authenticator
+//! from which a [`Channel`] can be opened.
+//!
+//! Supporting pieces include the [`Transport`] trait that identifies a specific
+//! transport implementation and the [`Ctap2AuthTokenStore`] trait for caching
+//! PIN/UV authentication tokens across operations. Each medium provides its own
+//! channel and device adapters suited to its protocol and hardware constraints.
+
pub(crate) mod error;
pub mod ble;
diff --git a/libwebauthn/src/u2f.rs b/libwebauthn/src/u2f.rs
index c448ad64..79481e6b 100644
--- a/libwebauthn/src/u2f.rs
+++ b/libwebauthn/src/u2f.rs
@@ -1,3 +1,13 @@
+//! High-level FIDO U2F (CTAP1) client API for registering and authenticating
+//! against an authenticator device. The [`U2F`] trait is blanket-implemented for
+//! any [`Channel`] and offers three async operations: protocol negotiation,
+//! registration, and signing.
+//!
+//! [`RegisterRequest`] and [`SignRequest`] describe the inputs, while the
+//! corresponding response types carry the results back to the caller. The trait
+//! handles the full lifecycle of a U2F exchange, from negotiating the device
+//! protocol through running the request with error handling.
+
use async_trait::async_trait;
use tracing::{instrument, warn};
diff --git a/libwebauthn/src/webauthn.rs b/libwebauthn/src/webauthn.rs
index ca537a06..f4031fec 100644
--- a/libwebauthn/src/webauthn.rs
+++ b/libwebauthn/src/webauthn.rs
@@ -1,3 +1,18 @@
+//! High-level FIDO2 (CTAP2) client API for WebAuthn ceremonies. The [`WebAuthn`]
+//! trait is blanket-implemented for any [`Channel`]. Its
+//! [`webauthn_make_credential`](WebAuthn::webauthn_make_credential) and
+//! [`webauthn_get_assertion`](WebAuthn::webauthn_get_assertion) methods run the
+//! full CTAP2 make-credential and get-assertion ceremonies, including the user
+//! verification flow, PIN and biometric token handling, credential filtering via
+//! preflight, and extension support. When a device does not support FIDO2, the
+//! ceremony falls back to U2F (CTAP1).
+//!
+//! User verification is handled internally by the [`pin_uv_auth_token`] module,
+//! which manages PIN and biometric UV, reuse of a cached pinUvAuthToken, shared
+//! secret establishment, and the fallback from biometric to PIN. Failures are
+//! reported as [`Error`], which distinguishes CTAP protocol errors
+//! ([`CtapError`]), transport errors, and platform errors.
+
pub mod error;
pub mod pin_uv_auth_token;
diff --git a/libwebauthn/tests/related_origins.rs b/libwebauthn/tests/related_origins.rs
new file mode 100644
index 00000000..d7c2b71e
--- /dev/null
+++ b/libwebauthn/tests/related_origins.rs
@@ -0,0 +1,141 @@
+//! End-to-end related-origins integration tests (WebAuthn L3 §5.11). Drives
+//! `prepare` with a mock HTTP client behind the well-known source and a tiny
+//! inline PSL. No network.
+
+use async_trait::async_trait;
+
+use libwebauthn::ops::webauthn::{
+ GetAssertionRequest, HttpClient, HttpClientError, MakeCredentialRequest, MaxRegistrableLabels,
+ OriginValidation, PublicSuffixList, RelatedOrigins, RequestOrigin, RequestSettings,
+ WellKnownRelatedOriginsSource,
+};
+
+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 HttpClient for StaticHttp {
+ async fn get(&self, _: &url::Url) -> Result>, HttpClientError> {
+ Ok(http::Response::builder()
+ .status(200)
+ .header(http::header::CONTENT_TYPE, "application/json")
+ .body(self.body.as_bytes().to_vec())
+ .unwrap())
+ }
+}
+
+const MAKE_CREDENTIAL_JSON: &str = r#"
+{
+ "rp": {"id": "example.org", "name": "example.org"},
+ "user": {
+ "id": "dXNlcmlk",
+ "name": "alice",
+ "displayName": "Alice"
+ },
+ "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`) so the
+// related-origins fetch path is actually exercised.
+const WELL_KNOWN_BODY: &str = r#"{"origins":["https://app.example.com","https://example.org"]}"#;
+
+fn settings<'a>(
+ psl: &'a TestPsl,
+ source: &'a WellKnownRelatedOriginsSource,
+) -> RequestSettings<'a> {
+ RequestSettings {
+ origin: OriginValidation::Validate {
+ public_suffix_list: psl,
+ related_origins: RelatedOrigins::Enabled {
+ source,
+ max_labels: MaxRegistrableLabels::default(),
+ },
+ },
+ }
+}
+
+#[tokio::test]
+async fn end_to_end_mock_match_via_make_credential() {
+ let request_origin: RequestOrigin = "https://app.example.com".parse().unwrap();
+ let psl = TestPsl;
+ let source = WellKnownRelatedOriginsSource::from_client(StaticHttp {
+ body: WELL_KNOWN_BODY,
+ });
+
+ let req = MakeCredentialRequest::prepare(
+ &request_origin,
+ MAKE_CREDENTIAL_JSON,
+ &settings(&psl, &source),
+ )
+ .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 psl = TestPsl;
+ let source = WellKnownRelatedOriginsSource::from_client(StaticHttp {
+ body: WELL_KNOWN_BODY,
+ });
+
+ let req = GetAssertionRequest::prepare(
+ &request_origin,
+ GET_ASSERTION_JSON,
+ &settings(&psl, &source),
+ )
+ .await
+ .unwrap();
+
+ assert_eq!(req.relying_party_id, "example.org");
+ assert!(req
+ .client_data_json()
+ .contains(r#""origin":"https://app.example.com""#));
+}