didbot_policy/
client_id.rs1use std::collections::BTreeSet;
4use std::fmt;
5
6#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct ClientId(String);
27
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30pub enum ClientIdError {
31 #[error("client_id `{0}` is not a URL")]
33 NotAUrl(String),
34 #[error("client_id `{given}` is not in canonical form, which is `{canonical}`")]
36 NotCanonical {
37 given: String,
39 canonical: String,
41 },
42 #[error("client_id `{0}` may not contain credentials")]
44 Credentials(String),
45 #[error("client_id `{0}` may not contain a fragment")]
47 Fragment(String),
48 #[error("client_id `{0}` must name a host with no trailing dot")]
51 Host(String),
52 #[error("local client_id `{client_id}` may carry only `redirect_uri` and `scope`, not `{parameter}`")]
55 LocalParameter {
56 client_id: String,
58 parameter: String,
60 },
61 #[error("local client_id `{0}` may carry `scope` only once")]
63 LocalScopeTwice(String),
64 #[error("local client_id `{0}` carries a redirect_uri that is not a URL with a host")]
66 LocalRedirectUri(String),
67}
68
69const LOCAL: &str = "http://localhost/";
71
72const LOCAL_REDIRECT_URIS: [&str; 2] = ["http://127.0.0.1/", "http://[::1]/"];
74
75const LOCAL_SCOPE: &str = "atproto";
77
78impl ClientId {
79 pub fn parse(raw: &str) -> Result<Self, ClientIdError> {
88 let url = url::Url::parse(raw).map_err(|_| ClientIdError::NotAUrl(raw.to_owned()))?;
89 if url.fragment().is_some() {
90 return Err(ClientIdError::Fragment(raw.to_owned()));
91 }
92 if !url.username().is_empty() || url.password().is_some() {
93 return Err(ClientIdError::Credentials(raw.to_owned()));
94 }
95 if is_local_form(&url) {
96 return local(raw, &url);
97 }
98 match url.host_str() {
99 Some(host) if !host.ends_with('.') => {}
100 _ => return Err(ClientIdError::Host(raw.to_owned())),
101 }
102 if url.as_str() != raw {
103 return Err(ClientIdError::NotCanonical {
104 given: raw.to_owned(),
105 canonical: url.as_str().to_owned(),
106 });
107 }
108 Ok(Self(raw.to_owned()))
109 }
110
111 #[must_use]
113 pub fn as_str(&self) -> &str {
114 &self.0
115 }
116}
117
118#[must_use]
124pub fn is_local_origin(origin: &str) -> bool {
125 url::Url::parse(origin).is_ok_and(|url| is_local_form(&url) && url.query().is_none())
126}
127
128fn is_local_form(url: &url::Url) -> bool {
132 url.scheme() == "http"
133 && url.host_str() == Some("localhost")
134 && url.port().is_none()
135 && url.path() == "/"
136}
137
138fn local(raw: &str, url: &url::Url) -> Result<ClientId, ClientIdError> {
141 let mut redirect_uris = BTreeSet::new();
142 let mut scope: Option<BTreeSet<String>> = None;
143 for (key, value) in url.query_pairs() {
144 match &*key {
145 "redirect_uri" => {
146 redirect_uris.insert(
147 portless(&value)
148 .ok_or_else(|| ClientIdError::LocalRedirectUri(raw.to_owned()))?,
149 );
150 }
151 "scope" if scope.is_some() => {
152 return Err(ClientIdError::LocalScopeTwice(raw.to_owned()));
153 }
154 "scope" => {
155 scope = Some(
156 value
157 .split(' ')
158 .filter(|atom| !atom.is_empty())
159 .map(str::to_owned)
160 .collect(),
161 );
162 }
163 _ => {
164 return Err(ClientIdError::LocalParameter {
165 client_id: raw.to_owned(),
166 parameter: key.into_owned(),
167 })
168 }
169 }
170 }
171 if redirect_uris.is_empty() {
172 redirect_uris = LOCAL_REDIRECT_URIS
173 .iter()
174 .map(|uri| (*uri).to_owned())
175 .collect();
176 }
177 let scope = scope.map_or_else(
178 || LOCAL_SCOPE.to_owned(),
179 |atoms| atoms.into_iter().collect::<Vec<_>>().join(" "),
180 );
181
182 let mut query = url::form_urlencoded::Serializer::new(String::new());
183 for redirect_uri in &redirect_uris {
184 query.append_pair("redirect_uri", redirect_uri);
185 }
186 query.append_pair("scope", &scope);
187 Ok(ClientId(format!("{LOCAL}?{}", query.finish())))
188}
189
190fn portless(redirect_uri: &str) -> Option<String> {
193 let mut url = url::Url::parse(redirect_uri).ok()?;
194 url.host_str()?;
195 url.set_port(None).ok()?;
196 Some(url.into())
197}
198
199impl fmt::Display for ClientId {
200 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201 f.write_str(&self.0)
202 }
203}
204
205impl AsRef<str> for ClientId {
206 fn as_ref(&self) -> &str {
207 &self.0
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214
215 #[test]
219 fn a_second_spelling_of_a_client_is_refused() {
220 for raw in [
221 "https://Bad.example/client.json",
222 "HTTPS://bad.example/client.json",
223 "https://bad.example:443/client.json",
224 "https://bad.example./client.json",
225 "https://user@bad.example/client.json",
226 "https://bad.example/client.json#top",
227 "https://b%61d.example/client.json",
228 "https://bad.example",
229 ] {
230 assert!(ClientId::parse(raw).is_err(), "{raw} was accepted");
231 }
232 assert!(ClientId::parse("https://bad.example/client.json").is_ok());
233 }
234
235 #[test]
236 fn a_refusal_names_the_canonical_spelling() {
237 assert_eq!(
238 ClientId::parse("https://Bad.example/client.json"),
239 Err(ClientIdError::NotCanonical {
240 given: "https://Bad.example/client.json".to_owned(),
241 canonical: "https://bad.example/client.json".to_owned(),
242 })
243 );
244 }
245
246 #[test]
249 fn every_spelling_of_a_local_client_is_judged_as_one() {
250 let canonical = "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb\
251 &scope=atproto+transition%3Ageneric";
252 for raw in [
253 "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A4567%2Fcb&scope=atproto%20transition%3Ageneric",
254 "http://localhost/?scope=transition%3Ageneric+atproto&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb",
255 "HTTP://LocalHost:80/?redirect_uri=http://127.0.0.1:9/cb&redirect_uri=http://127.0.0.1/cb\
256 &scope=atproto++transition:generic+atproto",
257 canonical,
258 ] {
259 let id = ClientId::parse(raw).unwrap_or_else(|err| panic!("{raw}: {err}"));
260 assert_eq!(id.as_str(), canonical, "{raw}");
261 }
262 }
263
264 #[test]
267 fn a_bare_local_client_is_spelled_with_the_defaults() {
268 let bare = ClientId::parse("http://localhost").unwrap();
269 assert_eq!(
270 bare.as_str(),
271 "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2F\
272 &redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2F&scope=atproto"
273 );
274 let named = ClientId::parse(
275 "http://localhost/?redirect_uri=http%3A%2F%2F%5B%3A%3A1%5D%2F\
276 &redirect_uri=http%3A%2F%2F127.0.0.1%2F&scope=atproto",
277 )
278 .unwrap();
279 assert_eq!(bare, named);
280 }
281
282 #[test]
285 fn a_local_client_carrying_more_than_the_profile_defines_is_refused() {
286 assert_eq!(
287 ClientId::parse("http://localhost/?scope=atproto&a=1"),
288 Err(ClientIdError::LocalParameter {
289 client_id: "http://localhost/?scope=atproto&a=1".to_owned(),
290 parameter: "a".to_owned(),
291 })
292 );
293 assert!(matches!(
294 ClientId::parse("http://localhost/?scope=atproto&scope=atproto"),
295 Err(ClientIdError::LocalScopeTwice(_))
296 ));
297 assert!(matches!(
298 ClientId::parse("http://localhost/?redirect_uri=nowhere"),
299 Err(ClientIdError::LocalRedirectUri(_))
300 ));
301 for raw in [
302 "http://localhost@bad.example/",
303 "http://user@localhost/?scope=atproto",
304 "http://localhost/?scope=atproto#x",
305 ] {
306 assert!(ClientId::parse(raw).is_err(), "{raw} was accepted");
307 }
308 }
309
310 #[test]
311 fn the_local_origin_is_http_localhost_and_nothing_else() {
312 assert!(is_local_origin("http://localhost"));
313 for origin in [
314 "https://localhost",
315 "http://localhost:8080",
316 "http://127.0.0.1",
317 "http://localhost.example",
318 "not an origin",
319 ] {
320 assert!(!is_local_origin(origin), "{origin}");
321 }
322 }
323
324 #[test]
327 fn a_localhost_url_with_a_port_or_a_path_is_not_the_local_form() {
328 for raw in [
329 "http://localhost:2470/client.json",
330 "http://localhost/client.json",
331 ] {
332 assert_eq!(ClientId::parse(raw).unwrap().as_str(), raw);
333 }
334 }
335}