Skip to main content

didbot_policy/
client_id.rs

1//! An OAuth client's identifier, in the one spelling a policy matches.
2
3use std::collections::BTreeSet;
4use std::fmt;
5
6/// An OAuth `client_id`, known to be in canonical form.
7///
8/// A rule denying a client compares its id as text. If one client could
9/// spell its id two ways — `https://Bad.example/…`, `https://bad.example:443/…`
10/// — the second spelling would walk past a rule written against the first.
11/// [`ClientId::parse`] accepts exactly one spelling: the one the `url` crate
12/// serializes the URL back to. atproto's reference authorization server
13/// refuses the same inputs, so a conformant client never sends another.
14///
15/// The atproto profile's local form, `http://localhost` with an optional
16/// query, is different. Its client builds it from its own query, and many
17/// spellings build the same document: `http://localhost/?a` and
18/// `http://localhost?a`, parameters in any order, ports in its redirect URIs,
19/// which the profile does not match. So [`ClientId::parse`] rewrites every
20/// spelling of one to a single one: `http://localhost/?`, then each
21/// `redirect_uri` without its port, sorted, then one `scope` with its atoms
22/// sorted. A parameter the profile does not define is refused. A local id
23/// with no `redirect_uri` or no `scope` is spelled with the profile's
24/// defaults.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct ClientId(String);
27
28/// Why a string is not a [`ClientId`].
29#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30pub enum ClientIdError {
31    /// The string does not parse as a URL.
32    #[error("client_id `{0}` is not a URL")]
33    NotAUrl(String),
34    /// The string parses, but is not the spelling it parses back to.
35    #[error("client_id `{given}` is not in canonical form, which is `{canonical}`")]
36    NotCanonical {
37        /// What the caller sent.
38        given: String,
39        /// The one spelling accepted for the same URL.
40        canonical: String,
41    },
42    /// The URL carries a username or password.
43    #[error("client_id `{0}` may not contain credentials")]
44    Credentials(String),
45    /// The URL carries a fragment.
46    #[error("client_id `{0}` may not contain a fragment")]
47    Fragment(String),
48    /// The URL names no host, or a host ending in a dot, which DNS resolves
49    /// the same as the host without it.
50    #[error("client_id `{0}` must name a host with no trailing dot")]
51    Host(String),
52    /// A local `client_id` carries a query parameter other than the
53    /// profile's two.
54    #[error("local client_id `{client_id}` may carry only `redirect_uri` and `scope`, not `{parameter}`")]
55    LocalParameter {
56        /// What the caller sent.
57        client_id: String,
58        /// The parameter the profile does not define.
59        parameter: String,
60    },
61    /// A local `client_id` carries `scope` more than once.
62    #[error("local client_id `{0}` may carry `scope` only once")]
63    LocalScopeTwice(String),
64    /// A local `client_id`'s `redirect_uri` is not a URL with a host.
65    #[error("local client_id `{0}` carries a redirect_uri that is not a URL with a host")]
66    LocalRedirectUri(String),
67}
68
69/// How every local `client_id` is spelled, up to its query.
70const LOCAL: &str = "http://localhost/";
71
72/// The redirect URIs a local client has when its `client_id` names none.
73const LOCAL_REDIRECT_URIS: [&str; 2] = ["http://127.0.0.1/", "http://[::1]/"];
74
75/// The scope a local client has when its `client_id` names none.
76const LOCAL_SCOPE: &str = "atproto";
77
78impl ClientId {
79    /// Accepts `raw` if it is a `client_id` in canonical form, and rewrites
80    /// a local one into its canonical form.
81    ///
82    /// # Errors
83    ///
84    /// Any spelling other than the canonical one, and any URL carrying
85    /// credentials, a fragment, or no usable host. A local one carrying a
86    /// parameter the profile does not define. See [`ClientIdError`].
87    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    /// The id, as the one string every copy of it holds.
112    #[must_use]
113    pub fn as_str(&self) -> &str {
114        &self.0
115    }
116}
117
118/// Whether `origin` is the origin of the profile's local form,
119/// `http://localhost`. A client the server shows with this origin built its
120/// own metadata from its `client_id`, so any process on its machine can send
121/// the same one. A client fetched from `http://localhost/…` on a development
122/// deployment has this origin too.
123#[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
128/// Whether `url` is the profile's local form: `http`, host `localhost`, no
129/// port and an empty path. Any other `http://localhost` URL is an ordinary
130/// `client_id` naming a document to fetch.
131fn 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
138/// The canonical spelling of the local `client_id` `raw`, which parsed as
139/// `url`.
140fn 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
190/// `redirect_uri` with any port removed, since the profile matches a local
191/// client's redirect URIs without one. `None` if it is not a URL with a host.
192fn 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    /// Every one of these reaches the same client as
216    /// `https://bad.example/client.json`, and would pass a rule written
217    /// against that spelling if it were accepted.
218    #[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    /// Every one of these builds the same local client, so a rule written
247    /// against one spelling has to match them all.
248    #[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    /// The profile's defaults are spelled out, so naming them changes
265    /// nothing.
266    #[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    /// The profile defines two parameters. Anything else is a way to make a
283    /// new spelling, so it is refused rather than dropped.
284    #[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    /// A port or a path makes it an ordinary `client_id`, judged by the
325    /// ordinary rule.
326    #[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}