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 !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 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 !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 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 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 !matching_origins.contains(&&decoded_effective_domain) {
247 return Err(WebauthnError::OriginRpMissmatch);
248 };
249
250 Ok(rp_id)
251 }
252}
253
254fn 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#[expect(async_fn_in_trait)]
274pub trait Fetcher {
275 async fn fetch_related_origins(&self, url: Url)
280 -> Result<RelatedOriginResponse, WebauthnError>;
281}
282
283pub struct RelatedOriginResponse {
285 pub payload: WellKnown,
287 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}