Skip to main content

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 !is_registrable_suffix_of_or_equal_to(effective_domain, 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 !is_registrable_suffix_of_or_equal_to(effective_rp_id, 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        // First check whether we even have a valid RP ID before attempting
184        // to see if we have the means to fetch .well-known. This ordering is
185        // important for the error variants we expect to see.
186        if let ControlFlow::Break(res) = self.assert_valid_rp_id(rp_id) {
187            return res;
188        }
189
190        let Some(ref fetcher) = self.fetcher else {
191            return Err(WebauthnError::OriginRpMissmatch);
192        };
193
194        let well_known_url = Url::parse(&format!("https://{rp_id}/.well-known/webauthn"))
195            .expect("Building well_known_url unexpectedly failed");
196
197        let RelatedOriginResponse { payload, final_url } =
198            fetcher.fetch_related_origins(well_known_url).await?;
199
200        if final_url
201            .domain()
202            .filter(|domain| is_registrable_suffix_of_or_equal_to(domain, rp_id))
203            .is_none()
204        {
205            return Err(WebauthnError::RedirectError);
206        }
207
208        let WellKnown { origins } = payload;
209
210        let origin_domains: Vec<_> = origins
211            .iter()
212            .filter_map(|origin| decode_host(origin.domain()?))
213            .collect();
214
215        let labels_to_origins: HashMap<_, _> = origin_domains
216            .iter()
217            .filter_map(|origin| {
218                let etld = self.tld_provider.effective_tld_plus_one(origin).ok()?;
219                let (label, _) = etld.split_once('.')?;
220                if label.is_empty() {
221                    None
222                } else {
223                    Some((label, origin))
224                }
225            })
226            .into_group_map();
227
228        // upper limit of registerable domain labels
229        if labels_to_origins.len() > Self::ORIGIN_LABEL_LIMIT {
230            return Err(WebauthnError::ExceedsMaxLabelLimit);
231        }
232
233        let decoded_effective_domain =
234            decode_host(effective_domain).ok_or(WebauthnError::InvalidRpId)?;
235        let Some((requesting_label, _)) = self
236            .tld_provider
237            .effective_tld_plus_one(&decoded_effective_domain)
238            .ok()
239            .and_then(|etld| etld.split_once('.'))
240        else {
241            return Err(WebauthnError::InvalidRpId);
242        };
243
244        let Some(matching_origins) = labels_to_origins.get(requesting_label) else {
245            return Err(WebauthnError::OriginRpMissmatch);
246        };
247
248        // If this passes, it means the requesting origin is in the related origins list.
249        if !matching_origins.contains(&&decoded_effective_domain) {
250            return Err(WebauthnError::OriginRpMissmatch);
251        };
252
253        Ok(rp_id)
254    }
255}
256
257/// Checks whether `host` is a registrable domain suffix of or is equal to `rp_id`,
258/// per <https://html.spec.whatwg.org/multipage/browsers.html#is-a-registrable-domain-suffix-of-or-is-equal-to>.
259///
260/// This requires that `host` either equals `rp_id` exactly, or ends with `".{rp_id}"`,
261/// preventing `evil-example.com` from matching `example.com`.
262fn is_registrable_suffix_of_or_equal_to(host: &str, rp_id: &str) -> bool {
263    host == rp_id
264        || host
265            .strip_suffix(rp_id)
266            .is_some_and(|prefix| prefix.ends_with('.'))
267}
268
269/// Returns a decoded [String] if the domain name is punycode otherwise
270/// the original string reference [str] is returned.
271fn decode_host(host: &str) -> Option<Cow<'_, str>> {
272    if host.split('.').any(|s| s.starts_with("xn--")) {
273        let (decoded, result) = idna::domain_to_unicode(host);
274        result.ok().map(|_| Cow::from(decoded))
275    } else {
276        Some(Cow::from(host))
277    }
278}
279
280/// A trait to implement fetching remote resources for RP ID validation.
281///
282/// The implementer should take the following into consideration:
283/// * Ensure a proper user agent is set
284/// * Ensure an appropriate timeout is set
285/// * Only follow HTTPS links and redirects
286/// * Limit the number of redirects
287/// * Set the `Accept` header to `application/json`
288#[expect(async_fn_in_trait)]
289pub trait Fetcher {
290    /// Fetch the related origins resources from a url.
291    ///
292    /// The URL provided here already points to the `/.well-known/webauthn` path of a domain,
293    /// the fetcher should use the url parameter without modifications.
294    async fn fetch_related_origins(&self, url: Url)
295    -> Result<RelatedOriginResponse, WebauthnError>;
296}
297
298/// The response to fetching a related origins resource.
299pub struct RelatedOriginResponse {
300    /// The deserialized payload of the resource, the source for this data should be in json format.
301    pub payload: WellKnown,
302    /// The final url of the request should the fetcher follow redirects.
303    pub final_url: Url,
304}
305
306impl Fetcher for () {
307    async fn fetch_related_origins(
308        &self,
309        _url: Url,
310    ) -> Result<RelatedOriginResponse, WebauthnError> {
311        Err(WebauthnError::FetcherError)
312    }
313}