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}