passkey_client/
rp_id_verifier.rs1use 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
24pub 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 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 pub fn allows_insecure_localhost(mut self, is_allowed: bool) -> Self {
52 self.allows_insecure_localhost = is_allowed;
53 self
54 }
55
56 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 if let ControlFlow::Break(res) = self.assert_valid_rp_id(effective_domain) {
92 return res;
93 }
94
95 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 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 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 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 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 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 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 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 !matching_origins.contains(&&decoded_effective_domain) {
250 return Err(WebauthnError::OriginRpMissmatch);
251 };
252
253 Ok(rp_id)
254 }
255}
256
257fn 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
269fn 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#[expect(async_fn_in_trait)]
289pub trait Fetcher {
290 async fn fetch_related_origins(&self, url: Url)
295 -> Result<RelatedOriginResponse, WebauthnError>;
296}
297
298pub struct RelatedOriginResponse {
300 pub payload: WellKnown,
302 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}