Skip to main content

feather_reader/oauth/
metadata.rs

1//! The OAuth client-identity documents: `client-metadata.json` and the
2//! `client_id` derived from it.
3//!
4//! Two client shapes, chosen by `ClientConfig::dev`:
5//!
6//! * **dev / localhost** — atproto's special *localhost development client*.
7//!   The `client_id` is `http://localhost` with `redirect_uri` and `scope`
8//!   encoded as query parameters; no JWKS and no published metadata document
9//!   are required, so a dev stack boots with zero PKI.
10//!
11//! * **production** — a confidential client with a real, edge-reachable
12//!   metadata document, a published JWKS, and `private_key_jwt` authentication
13//!   using the key from [`super::keys`].
14//!
15//! **The external URLs deliberately match the sidecar's.** `client_id` is not
16//! merely a config value — it IS the client's identity, and a PDS stores it
17//! against every existing grant. The sidecar is mounted at `/oauth` by the edge
18//! proxy, so its metadata document is externally `…/oauth/client-metadata.json`
19//! and its callback `…/oauth/callback`. Serving those same paths from the Rust
20//! app keeps `client_id` stable across the cutover, so existing authorizations
21//! survive and a rollback does not strand them either.
22
23use anyhow::{bail, Context as _, Result};
24use serde_json::{json, Value};
25
26/// Everything the client documents are derived from.
27///
28/// Fields are private and the only constructor is [`ClientConfig::new`], so a
29/// `ClientConfig` that exists has been validated. Leaving them public would
30/// make the validation advisory, and the value it guards — `client_id` — is the
31/// client's identity rather than a request parameter.
32pub struct ClientConfig {
33    /// Public base URL of the app, e.g. `https://feather-reader.com`.
34    public_url: String,
35    /// The OAuth scope string requested at authorize time.
36    scope: String,
37    /// Localhost development client (no PKI) rather than a confidential client.
38    dev: bool,
39}
40
41impl ClientConfig {
42    /// Build a validated config.
43    ///
44    /// `public_url` must be an absolute `http(s)` URL with **no path**, and must
45    /// be `https` outside dev. The path rule is the one that bites: the sidecar
46    /// is mounted under `/oauth`, so `SIDECAR_PUBLIC_URL` is documented as
47    /// `https://feather-reader.com/oauth` — and reusing that value here would
48    /// produce `…/oauth/oauth/client-metadata.json`, which the edge proxy does
49    /// not route, 404ing a `client_id` the PDS has already cached.
50    ///
51    /// Validated at construction rather than at use because `client_id` is the
52    /// client's identity: a wrong one is not a bad request, it is a different
53    /// client, and it is discovered only after users cannot log in.
54    pub fn new(public_url: &str, scope: &str, dev: bool) -> Result<Self> {
55        let parsed = url::Url::parse(public_url)
56            .with_context(|| format!("public_url {public_url:?} is not an absolute URL"))?;
57
58        match parsed.scheme() {
59            "https" => {}
60            "http" if dev => {}
61            "http" => bail!("public_url must be https outside dev, got {public_url:?}"),
62            other => bail!("public_url must be http(s), got scheme {other:?}"),
63        }
64        if !parsed.has_host() {
65            bail!("public_url {public_url:?} has no host");
66        }
67        if parsed.path() != "/" && !parsed.path().is_empty() {
68            bail!(
69                "public_url must be an origin with no path, got {public_url:?} \
70                 (path {:?}) — the /oauth prefix is added by this module, so \
71                 including it would publish a doubled client_id",
72                parsed.path()
73            );
74        }
75        // Query, fragment and userinfo are rejected rather than ignored: the
76        // stored value is concatenated with `/oauth/...`, so a query would
77        // produce `https://host?x=1/oauth/client-metadata.json`, and userinfo
78        // would publish credentials inside the client's identity and in every
79        // `redirect_uris` entry.
80        if parsed.query().is_some() {
81            bail!("public_url must not carry a query string, got {public_url:?}");
82        }
83        if parsed.fragment().is_some() {
84            bail!("public_url must not carry a fragment, got {public_url:?}");
85        }
86        if !parsed.username().is_empty() || parsed.password().is_some() {
87            bail!("public_url must not carry credentials, got {public_url:?}");
88        }
89
90        // Store the PARSED origin, not the input string: `Url` has already
91        // lowercased the scheme and host and dropped a default port, so the
92        // published `client_id` is stable however it was spelled in config.
93        let origin = parsed[..url::Position::AfterPort].to_string();
94        Ok(Self {
95            public_url: origin,
96            scope: scope.to_string(),
97            dev,
98        })
99    }
100
101    /// The requested scope, as sent in PAR and stored on the pending row.
102    pub fn scope_str(&self) -> &str {
103        &self.scope
104    }
105
106    /// The public base URL without a trailing slash, so the path joins below
107    /// cannot produce a `//`.
108    fn base(&self) -> &str {
109        self.public_url.trim_end_matches('/')
110    }
111}
112
113/// Where the browser is sent back to after the PDS authorizes (or denies).
114pub fn redirect_uri(cfg: &ClientConfig) -> String {
115    format!("{}/oauth/callback", cfg.base())
116}
117
118/// Where the published JWKS lives. Production only — the localhost dev client
119/// does not use one.
120pub fn jwks_uri(cfg: &ClientConfig) -> String {
121    format!("{}/oauth/jwks.json", cfg.base())
122}
123
124/// The client's identity.
125///
126/// In production this is the URL of the metadata document itself, which the PDS
127/// fetches. In dev it is atproto's localhost development client: the literal
128/// `http://localhost` with the redirect and scope in the query string.
129///
130/// Both query parameters are percent-encoded by the serializer rather than
131/// interpolated. `redirect_uri` contains `:` and `/`, and any `&` or `?` in a
132/// configured value would otherwise splice additional parameters into the
133/// client_id — a config value must never be able to redefine the redirect.
134pub fn client_id(cfg: &ClientConfig) -> String {
135    if !cfg.dev {
136        return format!("{}/oauth/client-metadata.json", cfg.base());
137    }
138    let query = url::form_urlencoded::Serializer::new(String::new())
139        .append_pair("redirect_uri", &redirect_uri(cfg))
140        .append_pair("scope", &cfg.scope)
141        .finish();
142    format!("http://localhost?{query}")
143}
144
145/// The `client-metadata.json` document.
146pub fn client_metadata(cfg: &ClientConfig) -> Value {
147    let mut doc = json!({
148        "client_id": client_id(cfg),
149        "client_name": if cfg.dev { "FeatherReader (dev)" } else { "FeatherReader" },
150        "redirect_uris": [redirect_uri(cfg)],
151        "scope": cfg.scope,
152        // `refresh_token` is required: without it the client cannot refresh and
153        // every session dies at access-token expiry.
154        "grant_types": ["authorization_code", "refresh_token"],
155        "response_types": ["code"],
156        "application_type": "web",
157        "dpop_bound_access_tokens": true,
158    });
159
160    let obj = doc.as_object_mut().expect("built from a json! object");
161    if cfg.dev {
162        // The localhost dev client publishes no JWKS and authenticates with
163        // nothing; advertising a jwks_uri we do not serve would make the PDS
164        // fetch a 404.
165        obj.insert("token_endpoint_auth_method".into(), json!("none"));
166    } else {
167        obj.insert("client_uri".into(), json!(cfg.base()));
168        obj.insert(
169            "token_endpoint_auth_method".into(),
170            json!("private_key_jwt"),
171        );
172        obj.insert("token_endpoint_auth_signing_alg".into(), json!("ES256"));
173        obj.insert("jwks_uri".into(), json!(jwks_uri(cfg)));
174    }
175    doc
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn prod() -> ClientConfig {
183        ClientConfig::new(
184            "https://feather-reader.com",
185            "atproto transition:generic",
186            false,
187        )
188        .unwrap()
189    }
190
191    fn dev() -> ClientConfig {
192        ClientConfig::new("http://127.0.0.1:8080", "atproto transition:generic", true).unwrap()
193    }
194
195    // ── configuration validation ─────────────────────────────────────────────
196
197    /// **The misconfiguration that is waiting to happen.** `SIDECAR_PUBLIC_URL`
198    /// is documented as `https://feather-reader.com/oauth`, because the sidecar
199    /// is mounted under that prefix — so that is exactly the value an operator
200    /// reaches for. Carrying the path through would yield
201    /// `…/oauth/oauth/client-metadata.json`; Caddy strips ONE `/oauth` prefix,
202    /// so the request 404s, every login breaks, and the PDS has already cached
203    /// that `client_id`.
204    #[test]
205    fn a_public_url_carrying_a_path_is_rejected() {
206        for url in [
207            "https://feather-reader.com/oauth",
208            "https://feather-reader.com/a/b",
209            "https://feather-reader.com/oauth/",
210        ] {
211            let cfg = ClientConfig::new(url, "atproto", false);
212            assert!(cfg.is_err(), "accepted a public_url with a path: {url}");
213        }
214    }
215
216    /// Checking only the path let these through, and the raw string was then
217    /// concatenated, so `client_id` came out as
218    /// `https://feather-reader.com?x=1/oauth/client-metadata.json` — or, worse,
219    /// published credentials inside the client's identity.
220    #[test]
221    fn a_public_url_carrying_a_query_fragment_or_credentials_is_rejected() {
222        for url in [
223            "https://feather-reader.com?x=1",
224            "https://feather-reader.com#frag",
225            "https://u:p@feather-reader.com",
226            "https://u@feather-reader.com",
227        ] {
228            assert!(
229                ClientConfig::new(url, "atproto", false).is_err(),
230                "accepted {url}"
231            );
232        }
233    }
234
235    /// The stored value is the PARSED origin, so casing is normalized and the
236    /// published `client_id` is stable regardless of how it was configured.
237    #[test]
238    fn the_origin_is_normalized_rather_than_echoed_back() {
239        let cfg = ClientConfig::new("HTTPS://Feather-Reader.COM", "atproto", false).unwrap();
240        assert_eq!(
241            client_id(&cfg),
242            "https://feather-reader.com/oauth/client-metadata.json"
243        );
244    }
245
246    #[test]
247    fn a_public_url_must_be_an_absolute_http_url() {
248        for url in [
249            "feather-reader.com",
250            "",
251            "/////",
252            "not a url",
253            "ftp://x.example",
254        ] {
255            assert!(
256                ClientConfig::new(url, "atproto", false).is_err(),
257                "accepted {url:?}"
258            );
259        }
260    }
261
262    /// Production `client_id` must be https — a PDS will not accept a plaintext
263    /// client identity. Dev runs on loopback http, which is the documented
264    /// exception.
265    #[test]
266    fn plain_http_is_rejected_in_production_but_allowed_in_dev() {
267        assert!(ClientConfig::new("http://feather-reader.com", "atproto", false).is_err());
268        assert!(ClientConfig::new("http://127.0.0.1:8080", "atproto", true).is_ok());
269    }
270
271    #[test]
272    fn a_valid_public_url_is_accepted_with_or_without_a_trailing_slash() {
273        assert!(ClientConfig::new("https://feather-reader.com", "atproto", false).is_ok());
274        assert!(ClientConfig::new("https://feather-reader.com/", "atproto", false).is_ok());
275    }
276
277    // ── the URLs that must not change ────────────────────────────────────────
278
279    /// `client_id` is the client's identity: a PDS stores it against every
280    /// existing grant. These are the paths the sidecar is reachable on today
281    /// (it is mounted at `/oauth` by the edge proxy), so keeping them keeps
282    /// existing authorizations valid across the cutover.
283    #[test]
284    fn the_production_urls_match_the_paths_the_sidecar_serves_today() {
285        let cfg = prod();
286        assert_eq!(
287            client_id(&cfg),
288            "https://feather-reader.com/oauth/client-metadata.json"
289        );
290        assert_eq!(
291            redirect_uri(&cfg),
292            "https://feather-reader.com/oauth/callback"
293        );
294        assert_eq!(jwks_uri(&cfg), "https://feather-reader.com/oauth/jwks.json");
295    }
296
297    #[test]
298    fn a_trailing_slash_on_the_public_url_is_normalized_away() {
299        let cfg = ClientConfig {
300            public_url: "https://feather-reader.com/".into(),
301            ..prod()
302        };
303        assert_eq!(
304            client_id(&cfg),
305            "https://feather-reader.com/oauth/client-metadata.json"
306        );
307        assert_eq!(
308            redirect_uri(&cfg),
309            "https://feather-reader.com/oauth/callback"
310        );
311    }
312
313    // ── production document ──────────────────────────────────────────────────
314
315    #[test]
316    fn the_production_document_is_a_dpop_bound_confidential_client() {
317        let doc = client_metadata(&prod());
318        assert_eq!(doc["client_id"], client_id(&prod()));
319        assert_eq!(doc["redirect_uris"][0], redirect_uri(&prod()));
320        assert_eq!(doc["jwks_uri"], jwks_uri(&prod()));
321        assert_eq!(doc["token_endpoint_auth_method"], "private_key_jwt");
322        assert_eq!(doc["token_endpoint_auth_signing_alg"], "ES256");
323        assert_eq!(doc["dpop_bound_access_tokens"], true);
324        assert_eq!(doc["application_type"], "web");
325        assert_eq!(doc["response_types"][0], "code");
326        let grants: Vec<&str> = doc["grant_types"]
327            .as_array()
328            .unwrap()
329            .iter()
330            .map(|g| g.as_str().unwrap())
331            .collect();
332        assert!(grants.contains(&"authorization_code"));
333        assert!(
334            grants.contains(&"refresh_token"),
335            "without refresh_token the client cannot refresh and sessions die at token expiry"
336        );
337    }
338
339    // ── dev document ─────────────────────────────────────────────────────────
340
341    /// The localhost dev client needs NO published metadata and NO JWKS — that
342    /// is the whole point of it. Advertising a `jwks_uri` we do not serve would
343    /// make the PDS fetch a 404.
344    #[test]
345    fn the_dev_document_declares_no_jwks_and_no_client_authentication() {
346        let doc = client_metadata(&dev());
347        assert!(doc.get("jwks_uri").is_none());
348        assert_eq!(doc["token_endpoint_auth_method"], "none");
349        assert_eq!(doc["dpop_bound_access_tokens"], true);
350    }
351
352    /// The dev `client_id` carries redirect_uri and scope in its QUERY STRING,
353    /// so both must be percent-encoded. Interpolating them raw would splice the
354    /// `:` and `/` of the redirect and, worse, let any `&` or `?` in a
355    /// configured value inject further parameters.
356    #[test]
357    fn the_dev_client_id_percent_encodes_its_query_parameters() {
358        let id = client_id(&dev());
359        assert!(id.starts_with("http://localhost?"), "got {id}");
360        assert!(
361            id.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2Foauth%2Fcallback"),
362            "redirect_uri was not percent-encoded: {id}"
363        );
364        // The space in "atproto transition:generic" must be encoded too.
365        assert!(
366            id.contains("scope=atproto+transition%3Ageneric")
367                || id.contains("scope=atproto%20transition%3Ageneric"),
368            "scope was not percent-encoded: {id}"
369        );
370        assert!(
371            !id.contains(' '),
372            "a raw space would make an invalid URL: {id}"
373        );
374    }
375
376    /// A hostile or fat-fingered config value must not be able to add
377    /// parameters to the dev client_id.
378    #[test]
379    fn a_query_delimiter_in_the_scope_cannot_inject_extra_parameters() {
380        let cfg = ClientConfig {
381            scope: "atproto&redirect_uri=https://evil.example".into(),
382            ..dev()
383        };
384        let id = client_id(&cfg);
385        assert_eq!(
386            id.matches("redirect_uri=").count(),
387            1,
388            "scope injected a second redirect_uri: {id}"
389        );
390        assert!(id.contains("%26"), "the `&` was not encoded: {id}");
391    }
392
393    #[test]
394    fn the_dev_document_and_client_id_agree_on_the_redirect_uri() {
395        let doc = client_metadata(&dev());
396        assert_eq!(doc["redirect_uris"][0], redirect_uri(&dev()));
397        assert_eq!(doc["client_id"], client_id(&dev()));
398    }
399}