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