passkey_client/
rp_id_verifier.rs

1use std::{borrow::Cow, collections::HashMap, ops::ControlFlow};
2
3use itertools::Itertools;
4use passkey_types::webauthn::WellKnown;
5use url::Url;
6
7use crate::{Origin, WebauthnError};
8
9#[cfg(doc)]
10use crate::Client;
11
12#[cfg(test)]
13pub(crate) mod tests;
14
15#[cfg(feature = "reqwest")]
16mod reqwest_fetcher;
17
18#[cfg(feature = "android-asset-validation")]
19pub(crate) mod android;
20
21#[cfg(feature = "android-asset-validation")]
22use android::UnverifiedAssetLink;
23
24/// Wrapper struct for verifying that a given RpId matches the request's origin.
25///
26/// While most cases should not use this type directly and instead use [`Client`], there are some
27/// cases that warrant the need for checking an RpId in the same way that the client does, but without
28/// the rest of pieces that the client needs.
29pub struct RpIdVerifier<P, F> {
30    tld_provider: Box<P>,
31    allows_insecure_localhost: bool,
32    fetcher: Option<F>,
33}
34
35impl<P, F> RpIdVerifier<P, F>
36where
37    P: public_suffix::EffectiveTLDProvider + Sync + 'static,
38    F: Fetcher + Sync,
39{
40    /// Create a new Verifier with a given TLD provider. Most cases should just use
41    /// [`public_suffix::DEFAULT_PROVIDER`].
42    pub fn new(tld_provider: P, fetcher: Option<F>) -> Self {
43        Self {
44            tld_provider: Box::new(tld_provider),
45            allows_insecure_localhost: false,
46            fetcher,
47        }
48    }
49
50    /// Allows [`RpIdVerifier::assert_domain`] to pass through requests from `localhost`
51    pub fn allows_insecure_localhost(mut self, is_allowed: bool) -> Self {
52        self.allows_insecure_localhost = is_allowed;
53        self
54    }
55
56    /// Parse the given Relying Party Id and verify it against the origin url of the request.
57    ///
58    /// This follows the steps defined in: <https://html.spec.whatwg.org/multipage/browsers.html#is-a-registrable-domain-suffix-of-or-is-equal-to>
59    ///
60    /// Returns the effective domain on success or some [`WebauthnError`]
61    pub async fn assert_domain<'a>(
62        &self,
63        origin: &'a Origin<'a>,
64        rp_id: Option<&'a str>,
65    ) -> Result<&'a str, WebauthnError> {
66        match origin {
67            Origin::Web(url) => self.assert_web_rp_id(url, rp_id).await,
68            #[cfg(feature = "android-asset-validation")]
69            Origin::Android(unverified) => self.assert_android_rp_id(unverified, rp_id),
70        }
71    }
72
73    async fn assert_web_rp_id<'a>(
74        &self,
75        origin: &'a Url,
76        rp_id: Option<&'a str>,
77    ) -> Result<&'a str, WebauthnError> {
78        let mut effective_domain = origin.domain().ok_or(WebauthnError::OriginMissingDomain)?;
79
80        if let Some(rp_id) = rp_id {
81            if !effective_domain.ends_with(rp_id) {
82                effective_domain = self
83                    .validate_related_origins(rp_id, effective_domain)
84                    .await?;
85            } else {
86                effective_domain = rp_id;
87            }
88        }
89
90        // Guard against local host and assert rp_id is not part of the public suffix list
91        if let ControlFlow::Break(res) = self.assert_valid_rp_id(effective_domain) {
92            return res;
93        }
94
95        // Make sure origin uses https://
96        if !(origin.scheme().eq_ignore_ascii_case("https")) {
97            return Err(WebauthnError::UnprotectedOrigin);
98        }
99
100        Ok(effective_domain)
101    }
102
103    fn assert_valid_rp_id<'a>(
104        &self,
105        rp_id: &'a str,
106    ) -> ControlFlow<Result<&'a str, WebauthnError>, ()> {
107        // guard against localhost effective domain, return early
108        if rp_id == "localhost" {
109            return if self.allows_insecure_localhost {
110                ControlFlow::Break(Ok(rp_id))
111            } else {
112                ControlFlow::Break(Err(WebauthnError::InsecureLocalhostNotAllowed))
113            };
114        }
115
116        // assert rp_id is not part of the public suffix list and is a registerable domain.
117        if decode_host(rp_id)
118            .as_ref()
119            .and_then(|s| self.tld_provider.effective_tld_plus_one(s).ok())
120            .is_none()
121        {
122            return ControlFlow::Break(Err(WebauthnError::InvalidRpId));
123        }
124
125        ControlFlow::Continue(())
126    }
127
128    /// Parse a given Relying Party ID and assert that it is valid to act as such.
129    ///
130    /// This method is only to assert that an RP ID passes the required checks.
131    /// In order to ensure that a request's origin is in accordance with it's claimed RP ID,
132    /// [`Self::assert_domain`] should be used.
133    ///
134    /// There are several checks that an RP ID must pass:
135    /// 1. An RP ID set to `localhost` is only allowed when explicitly enabled with [`Self::allows_insecure_localhost`].
136    /// 1. An RP ID must not be part of the [public suffix list],
137    ///    since that would allow it to act as a credential for unrelated services by other entities.
138    pub fn is_valid_rp_id(&self, rp_id: &str) -> bool {
139        match self.assert_valid_rp_id(rp_id) {
140            ControlFlow::Continue(_) | ControlFlow::Break(Ok(_)) => true,
141            ControlFlow::Break(Err(_)) => false,
142        }
143    }
144
145    #[cfg(feature = "android-asset-validation")]
146    fn assert_android_rp_id<'a>(
147        &self,
148        target_link: &'a UnverifiedAssetLink,
149        rp_id: Option<&'a str>,
150    ) -> Result<&'a str, WebauthnError> {
151        let mut effective_rp_id = target_link.host();
152
153        if let Some(rp_id) = rp_id {
154            // subset from assert_web_rp_id
155            if !effective_rp_id.ends_with(rp_id) {
156                return Err(WebauthnError::OriginRpMissmatch);
157            }
158            effective_rp_id = rp_id;
159        }
160
161        if decode_host(effective_rp_id)
162            .as_ref()
163            .and_then(|s| self.tld_provider.effective_tld_plus_one(s).ok())
164            .is_none()
165        {
166            return Err(WebauthnError::InvalidRpId);
167        }
168
169        // TODO: Find an ergonomic and caching friendly way to fetch the remote
170        // assetlinks and validate them here.
171        // https://github.com/1Password/passkey-rs/issues/13
172
173        Ok(effective_rp_id)
174    }
175
176    const ORIGIN_LABEL_LIMIT: usize = 5;
177
178    async fn validate_related_origins<'a>(
179        &self,
180        rp_id: &'a str,
181        effective_domain: &'a str,
182    ) -> Result<&'a str, WebauthnError> {
183        let Some(ref fetcher) = self.fetcher else {
184            return Err(WebauthnError::OriginRpMissmatch);
185        };
186
187        if let ControlFlow::Break(res) = self.assert_valid_rp_id(rp_id) {
188            return res;
189        }
190
191        let well_known_url = Url::parse(&format!("https://{rp_id}/.well-known/webauthn"))
192            .expect("Building well_known_url unexpectedly failed");
193
194        let RelatedOriginResponse { payload, final_url } =
195            fetcher.fetch_related_origins(well_known_url).await?;
196
197        if final_url
198            .domain()
199            .filter(|domain| domain.ends_with(rp_id))
200            .is_none()
201        {
202            return Err(WebauthnError::RedirectError);
203        }
204
205        let WellKnown { origins } = payload;
206
207        let origin_domains: Vec<_> = origins
208            .iter()
209            .filter_map(|origin| decode_host(origin.domain()?))
210            .collect();
211
212        let labels_to_origins: HashMap<_, _> = origin_domains
213            .iter()
214            .filter_map(|origin| {
215                let etld = self.tld_provider.effective_tld_plus_one(origin).ok()?;
216                let (label, _) = etld.split_once('.')?;
217                if label.is_empty() {
218                    None
219                } else {
220                    Some((label, origin))
221                }
222            })
223            .into_group_map();
224
225        // upper limit of registerable domain labels
226        if labels_to_origins.len() > Self::ORIGIN_LABEL_LIMIT {
227            return Err(WebauthnError::ExceedsMaxLabelLimit);
228        }
229
230        let decoded_effective_domain =
231            decode_host(effective_domain).ok_or(WebauthnError::InvalidRpId)?;
232        let Some((requesting_label, _)) = self
233            .tld_provider
234            .effective_tld_plus_one(&decoded_effective_domain)
235            .ok()
236            .and_then(|etld| etld.split_once('.'))
237        else {
238            return Err(WebauthnError::InvalidRpId);
239        };
240
241        let Some(matching_origins) = labels_to_origins.get(requesting_label) else {
242            return Err(WebauthnError::OriginRpMissmatch);
243        };
244
245        // If this passes, it means the requesting origin is in the related origins list.
246        if !matching_origins.contains(&&decoded_effective_domain) {
247            return Err(WebauthnError::OriginRpMissmatch);
248        };
249
250        Ok(rp_id)
251    }
252}
253
254/// Returns a decoded [String] if the domain name is punycode otherwise
255/// the original string reference [str] is returned.
256fn decode_host(host: &str) -> Option<Cow<'_, str>> {
257    if host.split('.').any(|s| s.starts_with("xn--")) {
258        let (decoded, result) = idna::domain_to_unicode(host);
259        result.ok().map(|_| Cow::from(decoded))
260    } else {
261        Some(Cow::from(host))
262    }
263}
264
265/// A trait to implement fetching remote resources for RP ID validation.
266///
267/// The implementer should take the following into consideration:
268/// * Ensure a proper user agent is set
269/// * Ensure an appropriate timeout is set
270/// * Only follow HTTPS links and redirects
271/// * Limit the number of redirects
272/// * Set the `Accept` header to `application/json`
273#[expect(async_fn_in_trait)]
274pub trait Fetcher {
275    /// Fetch the related origins resources from a url.
276    ///
277    /// The URL provided here already points to the `/.well-known/webauthn` path of a domain,
278    /// the fetcher should use the url parameter without modifications.
279    async fn fetch_related_origins(&self, url: Url)
280    -> Result<RelatedOriginResponse, WebauthnError>;
281}
282
283/// The response to fetching a related origins resource.
284pub struct RelatedOriginResponse {
285    /// The deserialized payload of the resource, the source for this data should be in json format.
286    pub payload: WellKnown,
287    /// The final url of the request should the fetcher follow redirects.
288    pub final_url: Url,
289}
290
291impl Fetcher for () {
292    async fn fetch_related_origins(
293        &self,
294        _url: Url,
295    ) -> Result<RelatedOriginResponse, WebauthnError> {
296        Err(WebauthnError::FetcherError)
297    }
298}