Skip to main content

finger_protocol/
webfinger.rs

1//! WebFinger (RFC 7033), the successor that honours finger by name.
2//!
3//! Where finger returned whatever text a host felt like printing, WebFinger
4//! returns a **JSON Resource Descriptor**: a subject, its aliases, and a list
5//! of typed links. It is what resolves `@alice@example.social` on the
6//! fediverse, and it is the discovery step in OpenID Connect.
7//!
8//! ## What this module does and does not do
9//!
10//! WebFinger rides on HTTPS, and this crate does not contain an HTTP client.
11//! It implements the two spec-shaped halves and leaves the GET to the caller,
12//! who almost certainly has an HTTP stack already:
13//!
14//! - [`request_url`] builds the well-known URI, with the resource and any
15//!   `rel` filters correctly encoded;
16//! - [`parse`] reads the JRD that comes back.
17//!
18//! Perform the request with `Accept: application/jrd+json` ([`MEDIA_TYPE`]).
19//!
20//! ```
21//! use finger_protocol::webfinger::{acct, request_url, parse};
22//!
23//! let url = request_url("example.social", &acct("alice", "example.social"), &["self"]);
24//! assert_eq!(
25//!     url,
26//!     "https://example.social/.well-known/webfinger\
27//!      ?resource=acct%3Aalice%40example.social&rel=self"
28//! );
29//!
30//! // ... GET that URL, then:
31//! let jrd = parse(r#"{"subject":"acct:alice@example.social",
32//!     "links":[{"rel":"self","type":"application/activity+json",
33//!               "href":"https://example.social/users/alice"}]}"#).unwrap();
34//! assert_eq!(jrd.link("self").unwrap().href.as_deref(),
35//!            Some("https://example.social/users/alice"));
36//! ```
37
38use std::collections::BTreeMap;
39
40use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
41use serde::{Deserialize, Serialize};
42
43/// The media type of a JRD document, for the request's `Accept` header.
44pub const MEDIA_TYPE: &str = "application/jrd+json";
45
46/// The path every WebFinger query is served from.
47pub const WELL_KNOWN_PATH: &str = "/.well-known/webfinger";
48
49/// Everything outside RFC 3986's unreserved set is encoded, so `acct:` and its
50/// `@` become `%3A` and `%40` the way RFC 7033's own examples show.
51const RESOURCE: &AsciiSet = &NON_ALPHANUMERIC
52    .remove(b'-')
53    .remove(b'.')
54    .remove(b'_')
55    .remove(b'~');
56
57/// Build an `acct:` URI (RFC 7565), the usual way to name a person.
58///
59/// ```
60/// assert_eq!(finger_protocol::webfinger::acct("bob", "example.com"),
61///            "acct:bob@example.com");
62/// ```
63pub fn acct(user: &str, host: &str) -> String {
64    format!("acct:{user}@{host}")
65}
66
67/// Build the WebFinger request URL for a resource at a host.
68///
69/// `rels` filters the response to the named link relations; an empty slice
70/// asks for everything. A server may ignore the filter, so a caller must not
71/// assume the absence of a link means the absence of the relation.
72pub fn request_url(host: &str, resource: &str, rels: &[&str]) -> String {
73    let mut url = format!(
74        "https://{host}{WELL_KNOWN_PATH}?resource={}",
75        utf8_percent_encode(resource, RESOURCE)
76    );
77    for rel in rels {
78        url.push_str("&rel=");
79        url.push_str(&utf8_percent_encode(rel, RESOURCE).to_string());
80    }
81    url
82}
83
84/// A JSON Resource Descriptor: WebFinger's answer.
85#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86pub struct Jrd {
87    /// The URI of the entity described, which may differ from the one asked
88    /// about (a server is allowed to answer about the canonical form).
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub subject: Option<String>,
91    /// Other URIs that name the same entity.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub aliases: Vec<String>,
94    /// Name/value pairs about the subject. Values are nullable per the spec.
95    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96    pub properties: BTreeMap<String, Option<String>>,
97    /// The typed links, which are the point of the document.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub links: Vec<Link>,
100}
101
102impl Jrd {
103    /// The first link with the given relation.
104    pub fn link(&self, rel: &str) -> Option<&Link> {
105        self.links.iter().find(|link| link.rel == rel)
106    }
107
108    /// Every link with the given relation, in document order.
109    pub fn links_with(&self, rel: &str) -> impl Iterator<Item = &Link> {
110        self.links.iter().filter(move |link| link.rel == rel)
111    }
112}
113
114/// One link relation in a [`Jrd`].
115#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
116pub struct Link {
117    /// The relation type: a registered name or a URI. The only required field.
118    pub rel: String,
119    /// The media type of the target.
120    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
121    pub media_type: Option<String>,
122    /// The target URI.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub href: Option<String>,
125    /// A URI template, used instead of `href` when the target is
126    /// parameterised (OpenID Connect issuer discovery does this).
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub template: Option<String>,
129    /// Human-readable titles, keyed by language tag (or `und`).
130    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
131    pub titles: BTreeMap<String, String>,
132    /// Name/value pairs about this link. Values are nullable per the spec.
133    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
134    pub properties: BTreeMap<String, Option<String>>,
135}
136
137/// Parse a JRD document.
138pub fn parse(json: &str) -> Result<Jrd, serde_json::Error> {
139    serde_json::from_str(json)
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    // The example from RFC 7033 section 3.1, trimmed.
147    const CAROL: &str = r#"{
148      "subject" : "acct:carol@example.com",
149      "aliases" : ["https://example.com/~carol/"],
150      "properties" : { "http://example.com/ns/role" : "employee", "http://x/nil" : null },
151      "links" : [
152        { "rel" : "http://webfinger.example/rel/profile-page",
153          "href" : "https://www.example.com/~carol/" },
154        { "rel" : "self", "type" : "application/activity+json",
155          "href" : "https://example.com/users/carol",
156          "titles" : { "en-us" : "Carol" } }
157      ]
158    }"#;
159
160    #[test]
161    fn an_account_uri_is_built_per_rfc_7565() {
162        assert_eq!(acct("bob", "example.com"), "acct:bob@example.com");
163    }
164
165    #[test]
166    fn the_resource_is_encoded_the_way_the_rfc_shows() {
167        let url = request_url("example.com", &acct("carol", "example.com"), &[]);
168        assert_eq!(
169            url,
170            "https://example.com/.well-known/webfinger?resource=acct%3Acarol%40example.com"
171        );
172    }
173
174    #[test]
175    fn rel_filters_append_in_order() {
176        let url = request_url("example.com", "acct:a@b", &["self", "http://x/y"]);
177        assert!(url.ends_with("&rel=self&rel=http%3A%2F%2Fx%2Fy"), "got {url}");
178    }
179
180    #[test]
181    fn a_jrd_round_trips_its_subject_aliases_and_links() {
182        let jrd = parse(CAROL).unwrap();
183        assert_eq!(jrd.subject.as_deref(), Some("acct:carol@example.com"));
184        assert_eq!(jrd.aliases, vec!["https://example.com/~carol/"]);
185        assert_eq!(jrd.links.len(), 2);
186    }
187
188    #[test]
189    fn a_link_is_found_by_relation_with_its_media_type() {
190        let jrd = parse(CAROL).unwrap();
191        let this = jrd.link("self").unwrap();
192        assert_eq!(this.media_type.as_deref(), Some("application/activity+json"));
193        assert_eq!(this.href.as_deref(), Some("https://example.com/users/carol"));
194        assert_eq!(this.titles.get("en-us").map(String::as_str), Some("Carol"));
195    }
196
197    #[test]
198    fn a_null_property_survives_as_a_present_key() {
199        let jrd = parse(CAROL).unwrap();
200        assert_eq!(jrd.properties.get("http://x/nil"), Some(&None));
201        assert_eq!(
202            jrd.properties.get("http://example.com/ns/role"),
203            Some(&Some("employee".to_string()))
204        );
205    }
206
207    #[test]
208    fn a_minimal_jrd_needs_only_links() {
209        let jrd = parse(r#"{"links":[{"rel":"self"}]}"#).unwrap();
210        assert_eq!(jrd.subject, None);
211        assert!(jrd.aliases.is_empty());
212        assert_eq!(jrd.links[0].rel, "self");
213    }
214
215    #[test]
216    fn a_link_without_a_rel_is_rejected() {
217        assert!(parse(r#"{"links":[{"href":"https://x/"}]}"#).is_err());
218    }
219}