Skip to main content

didbot_serve/oauth/
client_metadata.rs

1//! Fetching, validating, and caching OAuth client metadata documents.
2//!
3//! atproto's OAuth profile has no registered-client table: a `client_id` is
4//! an HTTPS URL, and the document served there *is* the client's
5//! registration, fetched fresh (or from a short-lived cache) on every use.
6//! `plan/app-allowlist.md` needs more than the URL to enforce "an app that
7//! changes its redirect URIs is a different app" — so [`ClientKey`] is
8//! computed over the validated document's own content, not over the
9//! `client_id` string, and it is what [`ClientMetadataCache`] keys on.
10//!
11//! This module does not decide whether a client is *admitted*; it decides
12//! whether a document is *usable* at all. Admission is
13//! `plan/app-allowlist.md`'s hook, called from `oauth::par` once a validated
14//! document exists.
15//!
16//! One `client_id` form is the exception to "fetched fresh": the atproto
17//! profile's "Localhost Client Development" `client_id`, exactly
18//! `http://localhost` with no port and an empty or `/` path. There is
19//! nothing to fetch — `parse_loopback_client_id` builds the document
20//! straight from that URL's own `redirect_uri`/`scope` query parameters, and
21//! [`resolve_client`] returns it before ever reaching the network.
22//!
23//! No other `client_id` may name a port: the profile says "there must not
24//! be a port number included". A development deployment reaches a client
25//! serving on another port through [`didbot_identity::did::RESOLVE_PORTS`],
26//! as it reaches a `did:web`.
27
28use std::collections::HashMap;
29use std::sync::RwLock;
30
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33use time::{Duration, OffsetDateTime};
34
35use crate::oauth::client_auth::{self, ClientAuthentication, Jwks};
36
37/// How long a fetched document is trusted before it is fetched again.
38///
39/// Short on purpose: `plan/app-allowlist.md` treats a redirect-URI change as
40/// a new app, and the whole point of keying on content is that this server
41/// notices a change quickly rather than trusting a stale document for a
42/// deployment's whole uptime.
43pub const CACHE_TTL: Duration = Duration::minutes(15);
44
45/// How many distinct `client_id`s [`MemoryClientMetadataCache`] holds at
46/// once.
47///
48/// `POST /oauth/par` is unauthenticated and the `client_id` it names is the
49/// caller's to choose, so every distinct string a caller invents is a
50/// cacheable entry. Without a bound that map only ever grows, which is a way
51/// to spend this server's memory while holding no credential. Past it the
52/// cache drops what it has held longest, so a spree of invented ids costs a
53/// re-fetch for the real clients rather than the process.
54pub const CACHE_CAPACITY: usize = 512;
55
56/// How long the whole client metadata fetch may take — connect, headers and
57/// body together, not just the handshake.
58///
59/// `client_id` is chosen by an unauthenticated caller, so the host on the
60/// other end of this GET is one an attacker picks. A `connect_timeout` alone
61/// would not bound a server that completes its handshake and then trickles
62/// the body forever; this is the total deadline, applied per request so it
63/// holds whichever [`reqwest::Client`] a deployment wires in.
64///
65/// Ten seconds because that is what [`crate::relay::REQUEST_TIMEOUT`] — the
66/// only other outbound HTTP deadline this crate sets — already uses, and it
67/// sits comfortably inside `crate::routes::REQUEST_TIMEOUT`'s thirty seconds
68/// for the inbound request this fetch happens underneath.
69pub const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
70
71/// The most of a client metadata document this server will read before
72/// giving up on it.
73///
74/// The body is read against this bound rather than to completion, so a host
75/// answering a `client_id` fetch with an endless stream costs this process a
76/// bounded amount of memory rather than all of it. Sixty-four kibibytes is
77/// this workspace's ceiling on a small JSON document read off input it does
78/// not control, and is orders of magnitude more than the handful of fields
79/// [`ClientMetadataDocument`] keeps.
80pub const MAX_DOCUMENT_BYTES: usize = 64 * 1024;
81
82/// A validated client metadata document.
83///
84/// Reduced to the fields this server's endpoints and `plan/app-allowlist.md`
85/// need. `plan/app-allowlist.md` also asks that `client_name`, `client_uri`
86/// and `logo_uri` be recorded and never displayed, since showing an
87/// untrusted client's own self-description to anyone is how impersonation
88/// works — this server has no human to show them to, so that requirement is
89/// met by construction, and they are kept here only for the log line that
90/// records them.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ClientMetadataDocument {
93    /// Must equal the URL it was fetched from; checked in [`validate`].
94    pub client_id: String,
95    /// What kind of client this is, which decides the redirects it may
96    /// register. Left out of the content key when it is the default, so a
97    /// document that says `web` and one that says nothing hash alike.
98    #[serde(default, skip_serializing_if = "ApplicationType::is_web")]
99    pub application_type: ApplicationType,
100    /// The applicant's own name for itself. Never displayed; see the module
101    /// doc.
102    pub client_name: Option<String>,
103    /// The applicant's own homepage. Never displayed.
104    pub client_uri: Option<String>,
105    /// The applicant's own logo. Never displayed.
106    pub logo_uri: Option<String>,
107    /// Where an authorization code may be delivered. Never empty after
108    /// validation.
109    pub redirect_uris: Vec<String>,
110    /// OAuth grant types this client uses. Defaults to
111    /// `["authorization_code"]` when absent.
112    pub grant_types: Vec<String>,
113    /// OAuth response types this client uses. Defaults to `["code"]`.
114    pub response_types: Vec<String>,
115    /// How this client authenticates at `par` and `token`; see
116    /// [`crate::oauth::client_auth`].
117    #[serde(flatten)]
118    pub authentication: ClientAuthentication,
119    /// The scope this client declares it may ask for, if it published one.
120    pub scope: Option<String>,
121    /// Must be `true`: the atproto OAuth profile requires every access token
122    /// to be DPoP-bound, and a client that has not opted into that is not
123    /// one this server's tokens can be issued to.
124    pub dpop_bound_access_tokens: bool,
125}
126
127/// A document's `application_type`, which the atproto profile borrows from
128/// OpenID Connect. Absent, it is `web`; any other word refuses the document.
129#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "lowercase")]
131pub enum ApplicationType {
132    /// A web service or a browser app. Its redirects are `https://`, or
133    /// `http://` on a loopback host.
134    #[default]
135    Web,
136    /// A mobile or desktop app. Its redirects follow the profile's rules for
137    /// native clients; see `is_native_redirect_uri` (this module, private).
138    Native,
139}
140
141impl ApplicationType {
142    fn is_web(&self) -> bool {
143        *self == Self::Web
144    }
145}
146
147/// Why a client metadata document could not be used.
148#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
149pub enum ClientMetadataError {
150    /// `client_id` is not an `https://` URL (loopback `http://` is the one
151    /// exception, for development, mirroring `didbot_identity`'s own
152    /// loopback carve-out for `did:web`).
153    #[error("client_id `{0}` must be an https:// URL")]
154    NotHttps(String),
155    /// `client_id` has a fragment, which the spec forbids — a fragment is
156    /// never sent to a server, so two client IDs differing only in one would
157    /// be indistinguishable to anyone fetching them.
158    #[error("client_id `{0}` may not contain a fragment")]
159    HasFragment(String),
160    /// `client_id` names a port, which the profile forbids outside its
161    /// `http://localhost` form.
162    #[error("client_id `{0}` may not name a port")]
163    HasPort(String),
164    /// The document could not be fetched at all.
165    #[error("fetching client metadata for `{0}` failed: {1}")]
166    Fetch(String, String),
167    /// The document did not parse as the expected shape.
168    #[error("client metadata document at `{0}` did not parse: {1}")]
169    Malformed(String, String),
170    /// The document's own `client_id` did not equal the URL it was served
171    /// from — the binding the whole scheme depends on.
172    #[error("document at `{fetched_from}` claims client_id `{claimed}`, which does not match")]
173    ClientIdMismatch {
174        /// The URL this server fetched.
175        fetched_from: String,
176        /// What the document claimed instead.
177        claimed: String,
178    },
179    /// No `redirect_uris`, or every one of them is unusable.
180    #[error("client metadata for `{0}` has no usable redirect_uris")]
181    NoRedirectUris(String),
182    /// A web client's `redirect_uris` entry is not `https://`, or carries a
183    /// fragment.
184    #[error("a web client's redirect_uri `{0}` must be https:// with no fragment")]
185    BadRedirectUri(String),
186    /// A native client's `redirect_uris` entry breaks the profile's rules for
187    /// native clients; see `is_native_redirect_uri` (this module, private).
188    #[error(
189        "a native client's redirect_uri `{0}` must be its client_id's host in reverse-domain \
190         order followed by `:/` and a path, https:// on its client_id's origin, or http:// on \
191         127.0.0.1 or [::1], with no fragment"
192    )]
193    BadNativeRedirectUri(String),
194    /// The fetch did not stay at `client_id`.
195    ///
196    /// A client metadata document *is* its `client_id`: the URL is the
197    /// registration. A redirect means the document was served by something
198    /// other than the identifier it claims, and nothing downstream re-checks
199    /// where it actually landed — [`validate`] compares the document against
200    /// the requested `client_id`, so an open redirect at an otherwise
201    /// well-behaved `client_id` host would let a third party supply that
202    /// host's registration. Refused rather than capped for that reason.
203    #[error("client metadata fetch for `{client_id}` was redirected to `{to}`")]
204    Redirected {
205        /// The `client_id` that was requested.
206        client_id: String,
207        /// Where the response tried to send this server instead.
208        to: String,
209    },
210    /// The fetch was answered with a status other than `200 OK`, which the
211    /// profile requires ("not another 2xx or a redirect").
212    #[error("client metadata fetch for `{client_id}` answered {status}, not 200 OK")]
213    NotOk {
214        /// The `client_id` whose document, or key set, was fetched.
215        client_id: String,
216        /// What the fetch was answered with.
217        status: reqwest::StatusCode,
218    },
219    /// The document was not served as `application/json`, which the profile
220    /// requires.
221    #[error(
222        "client metadata for `{client_id}` was served as `{content_type}`, not application/json"
223    )]
224    NotJson {
225        /// The `client_id` whose document was fetched.
226        client_id: String,
227        /// The `Content-Type` it was served with, or nothing.
228        content_type: String,
229    },
230    /// The document is larger than [`MAX_DOCUMENT_BYTES`].
231    #[error("client metadata document at `{0}` is larger than {MAX_DOCUMENT_BYTES} bytes")]
232    TooLarge(String),
233    /// `dpop_bound_access_tokens` was absent or `false`.
234    #[error("client metadata for `{0}` does not opt into DPoP-bound access tokens")]
235    NotDpopBound(String),
236    /// `token_endpoint_auth_method` is neither `none` nor `private_key_jwt`.
237    #[error(
238        "client metadata for `{0}` names a token_endpoint_auth_method this server does not accept"
239    )]
240    UnsupportedAuthMethod(String),
241    /// `token_endpoint_auth_signing_alg` is `none`, or a confidential client
242    /// names no algorithm this server verifies.
243    #[error(
244        "client metadata for `{0}` names no token_endpoint_auth_signing_alg this server verifies"
245    )]
246    UnsupportedSigningAlg(String),
247    /// A confidential client publishes no key a client assertion can be
248    /// checked against.
249    #[error(
250        "client metadata for `{0}` publishes no key a client assertion can be checked against"
251    )]
252    NoClientKeys(String),
253    /// A confidential client names both `jwks` and `jwks_uri`, which the
254    /// atproto profile forbids.
255    #[error("client metadata for `{0}` names both jwks and jwks_uri")]
256    TwoKeySets(String),
257    /// `jwks_uri` is not `https://` (or loopback `http://` on a deployment
258    /// that fetches from its own machine), or carries a fragment.
259    #[error("jwks_uri `{0}` must be https://, or http:// on a loopback host, with no fragment")]
260    BadJwksUri(String),
261    /// A loopback `client_id`'s `redirect_uri` query parameter named a host
262    /// other than `127.0.0.1` or `[::1]` — the atproto profile's "Localhost
263    /// Client Development" form allows any port on either, and nothing
264    /// else, including `localhost` itself.
265    #[error("loopback redirect_uri `{0}` must be http://127.0.0.1 or http://[::1], any port")]
266    LoopbackRedirectUri(String),
267    /// `client_id`'s host is, or resolves to, an address outside
268    /// [`didbot_http::REFUSED_BLOCKS`] — this deployment's own network
269    /// rather than the public internet.
270    #[error("client_id `{0}` resolves to an address this server will not fetch from")]
271    RefusedAddress(String),
272}
273
274/// A client for fetching `url`, pinned to the addresses its host resolves
275/// to, and refused unless every one of them is one
276/// [`didbot_http::is_public_address`] allows.
277///
278/// `POST /oauth/par` is unauthenticated and `client_id` is a URL the caller
279/// chose, so without this the fetch is a request this server makes into
280/// whatever network it is deployed in, aimed by a stranger. The pin is what
281/// keeps the check true at connect time: a client that resolved the name
282/// again could be answered with a private address the second time.
283///
284/// Every refusal is the same answer: a host that resolves to nothing at all
285/// is refused here rather than left to fail in the fetch, so that both
286/// outcomes cost the same and neither is distinguishable from the other --
287/// see [`resolve_client`].
288async fn pinned_public_client(
289    client_id: &str,
290    url: &reqwest::Url,
291) -> Result<reqwest::Client, ClientMetadataError> {
292    didbot_http::pinned_client(url, didbot_http::PrivateAddresses::Refused)
293        .await
294        .map_err(|error| match error {
295            didbot_http::AddressError::NoClient { message, .. } => {
296                ClientMetadataError::Fetch(client_id.to_owned(), message)
297            }
298            _ => ClientMetadataError::RefusedAddress(url.to_string()),
299        })
300}
301
302/// Whether this deployment will fetch a client metadata document over
303/// loopback `http://`.
304///
305/// A development client serves its document from its own machine, which
306/// this server would then fetch. On a real deployment that is an outbound
307/// GET to the machine running the PDS, with the outcome readable from the
308/// error it answers: a probe of its own network reachable from an
309/// unauthenticated endpoint. A deployment under `.localhost` is a
310/// development one and needs the fetch; every other deployment refuses it.
311///
312/// The profile's `http://localhost` `client_id` form is built here from its
313/// own query and fetches nothing, so every deployment accepts it; see
314/// `parse_loopback_client_id`.
315#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum LoopbackClients {
317    /// Fetch them — a development deployment.
318    Fetched,
319    /// Refuse them.
320    Refused,
321}
322
323impl LoopbackClients {
324    /// What a deployment serving `zone` does. `.localhost` and `localhost`
325    /// itself are development zones; nothing else is.
326    pub fn for_zone(zone: &str) -> Self {
327        let zone = zone.trim_end_matches('.').to_ascii_lowercase();
328        if zone == "localhost" || zone.ends_with(".localhost") {
329            Self::Fetched
330        } else {
331            Self::Refused
332        }
333    }
334}
335
336/// Checks a URL is `https://` (or loopback `http://`) with no fragment.
337fn validate_url_scheme(raw: &str) -> Result<reqwest::Url, ()> {
338    let url = reqwest::Url::parse(raw).map_err(|_| ())?;
339    let ok_scheme =
340        url.scheme() == "https" || (url.scheme() == "http" && didbot_http::is_loopback(&url));
341    if !ok_scheme || url.fragment().is_some() {
342        return Err(());
343    }
344    Ok(url)
345}
346
347/// Whether `raw` is a usable loopback redirect URI: `http://127.0.0.1` or
348/// `http://[::1]`, any port, per the atproto profile's "Localhost Client
349/// Development" form. Stricter than [`didbot_http::is_loopback`] on
350/// purpose — that one also accepts the literal name `localhost`, which this
351/// form forbids for a redirect.
352fn is_loopback_redirect_uri(raw: &str) -> bool {
353    let Ok(url) = reqwest::Url::parse(raw) else {
354        return false;
355    };
356    // `Url::host_str` keeps an IPv6 literal's brackets (`"[::1]"`), unlike
357    // `Url::host()`'s `HostAddr::Ipv6`.
358    url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1") | Some("[::1]"))
359}
360
361/// Whether a web client may register `raw` as a redirect: the profile says
362/// a web client's redirect "is a HTTPS URL". A loopback `http://` redirect
363/// belongs to a native client.
364fn is_web_redirect_uri(raw: &str) -> bool {
365    reqwest::Url::parse(raw).is_ok_and(|url| url.scheme() == "https" && url.fragment().is_none())
366}
367
368/// Whether a native client named `client_id` may register `raw` as a
369/// redirect, by the atproto profile's rules for native clients.
370///
371/// A custom scheme must be the `client_id`'s host in reverse-domain order,
372/// followed by a single `:/` and a path: `com.example.app:/callback` for
373/// `https://app.example.com/...`. An `https://` redirect must be on the
374/// `client_id`'s own origin. An `http://` one is the loopback form
375/// [`is_loopback_redirect_uri`] accepts. None may carry a fragment.
376fn is_native_redirect_uri(client_id: &str, raw: &str) -> bool {
377    let (Ok(client), Ok(url)) = (reqwest::Url::parse(client_id), reqwest::Url::parse(raw)) else {
378        return false;
379    };
380    if url.fragment().is_some() {
381        return false;
382    }
383    match url.scheme() {
384        "https" => url.origin() == client.origin(),
385        "http" => is_loopback_redirect_uri(raw),
386        scheme => {
387            let Some(host) = client.host_str() else {
388                return false;
389            };
390            let reversed = host.rsplit('.').collect::<Vec<_>>().join(".");
391            // The serialization starts with the scheme and its colon.
392            let rest = &url.as_str()[scheme.len() + 1..];
393            scheme == reversed && rest.starts_with('/') && !rest.starts_with("//")
394        }
395    }
396}
397
398impl ClientMetadataDocument {
399    /// Whether a push may name `requested` as its redirect.
400    ///
401    /// Exact, except for a loopback redirect (`http://127.0.0.1` or
402    /// `http://[::1]`), which matches on everything but the port. A native
403    /// client listens on whatever port it is handed when it starts: RFC 8252
404    /// §7.3 says "the authorization server MUST allow any port to be
405    /// specified at the time of the request for loopback IP redirect URIs",
406    /// and the atproto profile says of its loopback clients "Path components
407    /// must match, but port numbers are not matched."
408    pub fn admits_redirect_uri(&self, requested: &str) -> bool {
409        self.redirect_uris.iter().any(|registered| {
410            registered == requested || same_loopback_redirect(registered, requested)
411        })
412    }
413}
414
415/// Whether `requested` is the loopback redirect `registered`, on any port.
416///
417/// Both are parsed, so `http://127.0.0.1` and `http://127.0.0.1/` are the
418/// same redirect, as they are to the client that listens on it.
419fn same_loopback_redirect(registered: &str, requested: &str) -> bool {
420    if !is_loopback_redirect_uri(registered) {
421        return false;
422    }
423    let (Ok(registered), Ok(mut requested)) = (
424        reqwest::Url::parse(registered),
425        reqwest::Url::parse(requested),
426    ) else {
427        return false;
428    };
429    requested.set_port(registered.port()).is_ok() && requested == registered
430}
431
432/// Recognises the atproto OAuth profile's "Localhost Client Development"
433/// `client_id` form — exactly `http://localhost`, no port, path empty or
434/// `/` — and builds its virtual client metadata document straight from the
435/// `redirect_uri` (repeatable) and `scope` query parameters, fetching
436/// nothing.
437///
438/// `None` means `client_id` is not this form, including a `localhost`
439/// `client_id` that carries a port or a deeper path, which
440/// [`resolve_client`] judges like any other. `Some` carries the
441/// built-and-validated document, or the one way building it can fail.
442fn parse_loopback_client_id(
443    client_id: &str,
444) -> Option<Result<ClientMetadataDocument, ClientMetadataError>> {
445    let url = reqwest::Url::parse(client_id).ok()?;
446    if url.scheme() != "http" {
447        return None;
448    }
449    if !url
450        .host_str()
451        .is_some_and(|host| host.eq_ignore_ascii_case("localhost"))
452    {
453        return None;
454    }
455    if url.port().is_some() {
456        return None;
457    }
458    if !matches!(url.path(), "" | "/") {
459        return None;
460    }
461
462    let mut redirect_uris: Vec<String> = url
463        .query_pairs()
464        .filter(|(key, _)| key == "redirect_uri")
465        .map(|(_, value)| value.into_owned())
466        .collect();
467    if redirect_uris.is_empty() {
468        redirect_uris = vec!["http://127.0.0.1/".to_owned(), "http://[::1]/".to_owned()];
469    }
470    for redirect_uri in &redirect_uris {
471        if !is_loopback_redirect_uri(redirect_uri) {
472            return Some(Err(ClientMetadataError::LoopbackRedirectUri(
473                redirect_uri.clone(),
474            )));
475        }
476    }
477
478    let scope = url
479        .query_pairs()
480        .find(|(key, _)| key == "scope")
481        .map(|(_, value)| value.into_owned())
482        .unwrap_or_else(|| "atproto".to_owned());
483
484    Some(Ok(ClientMetadataDocument {
485        client_id: client_id.to_owned(),
486        // The profile's virtual document for this form says so.
487        application_type: ApplicationType::Native,
488        client_name: None,
489        client_uri: None,
490        logo_uri: None,
491        redirect_uris,
492        grant_types: vec!["authorization_code".to_owned(), "refresh_token".to_owned()],
493        response_types: vec!["code".to_owned()],
494        authentication: ClientAuthentication::public(),
495        scope: Some(scope),
496        dpop_bound_access_tokens: true,
497    }))
498}
499
500/// Validates a raw fetched document against `client_id`, the URL it was
501/// fetched from.
502pub fn validate(
503    client_id: &str,
504    mut document: ClientMetadataDocument,
505) -> Result<ClientMetadataDocument, ClientMetadataError> {
506    if document.client_id != client_id {
507        return Err(ClientMetadataError::ClientIdMismatch {
508            fetched_from: client_id.to_owned(),
509            claimed: document.client_id,
510        });
511    }
512    if document.grant_types.is_empty() {
513        document.grant_types = vec!["authorization_code".to_owned()];
514    }
515    if document.response_types.is_empty() {
516        document.response_types = vec!["code".to_owned()];
517    }
518    if document.redirect_uris.is_empty() {
519        return Err(ClientMetadataError::NoRedirectUris(client_id.to_owned()));
520    }
521    for redirect_uri in &document.redirect_uris {
522        match document.application_type {
523            ApplicationType::Web if !is_web_redirect_uri(redirect_uri) => {
524                return Err(ClientMetadataError::BadRedirectUri(redirect_uri.clone()));
525            }
526            ApplicationType::Native if !is_native_redirect_uri(client_id, redirect_uri) => {
527                return Err(ClientMetadataError::BadNativeRedirectUri(
528                    redirect_uri.clone(),
529                ));
530            }
531            _ => {}
532        }
533    }
534    if !document.dpop_bound_access_tokens {
535        return Err(ClientMetadataError::NotDpopBound(client_id.to_owned()));
536    }
537    check_authentication(client_id, &document.authentication)?;
538    Ok(document)
539}
540
541/// Refuses what the atproto profile's "Confidential Client Authentication"
542/// refuses of a document's client authentication fields.
543///
544/// A confidential client's `jwks_uri` is checked here as a URL and fetched by
545/// [`resolve_client`].
546fn check_authentication(
547    client_id: &str,
548    authentication: &ClientAuthentication,
549) -> Result<(), ClientMetadataError> {
550    let alg = authentication.token_endpoint_auth_signing_alg.as_deref();
551    // "`none` is never allowed here", whichever method the client names.
552    if alg == Some("none") {
553        return Err(ClientMetadataError::UnsupportedSigningAlg(
554            client_id.to_owned(),
555        ));
556    }
557    match authentication.token_endpoint_auth_method.as_str() {
558        client_auth::NONE => Ok(()),
559        client_auth::PRIVATE_KEY_JWT => {
560            if alg != Some(client_auth::SIGNING_ALG) {
561                return Err(ClientMetadataError::UnsupportedSigningAlg(
562                    client_id.to_owned(),
563                ));
564            }
565            match (&authentication.jwks, &authentication.jwks_uri) {
566                (Some(_), Some(_)) => Err(ClientMetadataError::TwoKeySets(client_id.to_owned())),
567                (Some(jwks), None) if !jwks.is_empty() => Ok(()),
568                (None, Some(jwks_uri)) => validate_url_scheme(jwks_uri)
569                    .map(drop)
570                    .map_err(|()| ClientMetadataError::BadJwksUri(jwks_uri.clone())),
571                _ => Err(ClientMetadataError::NoClientKeys(client_id.to_owned())),
572            }
573        }
574        _ => Err(ClientMetadataError::UnsupportedAuthMethod(
575            client_id.to_owned(),
576        )),
577    }
578}
579
580/// A validated document's own identity, for [`ClientMetadataCache`] and for
581/// `plan/app-allowlist.md`'s "an admitted app that changes its redirect URIs
582/// is a different app" — computed over the document's content, so two
583/// fetches of the same `client_id` that disagree produce two different keys.
584#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
585pub struct ClientKey(pub String);
586
587impl ClientKey {
588    /// Hashes a validated document into its key.
589    ///
590    /// `serde_json::to_vec` over a `struct` (rather than a `Value` built from
591    /// a `HashMap`) is deterministic — struct fields serialize in
592    /// declaration order — so the same document always hashes the same way,
593    /// which is the one property this has to have.
594    pub fn compute(document: &ClientMetadataDocument) -> Self {
595        let bytes = serde_json::to_vec(document)
596            .expect("ClientMetadataDocument has no non-serializable field");
597        let digest = Sha256::digest(bytes);
598        Self(hex::encode(digest))
599    }
600}
601
602/// A cached, validated document plus when it was fetched.
603#[derive(Debug, Clone)]
604pub struct CachedClientMetadata {
605    /// The document as validated.
606    pub document: ClientMetadataDocument,
607    /// The document's content key; see [`ClientKey`].
608    pub key: ClientKey,
609    /// When this entry was fetched, for [`CACHE_TTL`] expiry.
610    pub fetched_at: OffsetDateTime,
611}
612
613/// Where fetched client metadata is cached, keyed by `client_id`.
614///
615/// A cache hit still names the document by its [`ClientKey`], which is what
616/// `plan/app-allowlist.md`'s admission check keys its list on — the cache is
617/// keyed by URL because that is what a client presents, and it hands back
618/// the content key alongside the document so a caller never has to choose
619/// which one to trust.
620pub trait ClientMetadataCache: Send + Sync {
621    /// A fresh cached entry for `client_id`, if one exists and has not
622    /// expired.
623    fn get(&self, client_id: &str) -> Option<CachedClientMetadata>;
624    /// Stores (or replaces) the entry for `client_id`.
625    fn put(&self, client_id: &str, entry: CachedClientMetadata);
626}
627
628/// The in-process client metadata cache.
629#[derive(Debug, Default)]
630pub struct MemoryClientMetadataCache {
631    entries: RwLock<HashMap<String, CachedClientMetadata>>,
632}
633
634impl MemoryClientMetadataCache {
635    /// An empty cache.
636    pub fn new() -> Self {
637        Self::default()
638    }
639}
640
641impl ClientMetadataCache for MemoryClientMetadataCache {
642    fn get(&self, client_id: &str) -> Option<CachedClientMetadata> {
643        let entries = self
644            .entries
645            .read()
646            .unwrap_or_else(|poisoned| poisoned.into_inner());
647        let entry = entries.get(client_id)?;
648        if OffsetDateTime::now_utc() - entry.fetched_at > CACHE_TTL {
649            return None;
650        }
651        Some(entry.clone())
652    }
653
654    /// Stores the entry, dropping expired ones first and then the entry
655    /// held longest if the cache is still at [`CACHE_CAPACITY`].
656    ///
657    /// Swept on write, the way `oauth::par`'s and `oauth::consent`'s own
658    /// stores sweep theirs: no background task, and a caller's cost does not
659    /// grow with how many other callers there are.
660    fn put(&self, client_id: &str, entry: CachedClientMetadata) {
661        let now = OffsetDateTime::now_utc();
662        let mut entries = self
663            .entries
664            .write()
665            .unwrap_or_else(|poisoned| poisoned.into_inner());
666        entries.retain(|_, held| now - held.fetched_at <= CACHE_TTL);
667        while entries.len() >= CACHE_CAPACITY && !entries.contains_key(client_id) {
668            let Some(oldest) = entries
669                .iter()
670                .min_by_key(|(_, held)| held.fetched_at)
671                .map(|(id, _)| id.clone())
672            else {
673                break;
674            };
675            entries.remove(&oldest);
676        }
677        entries.insert(client_id.to_owned(), entry);
678    }
679}
680
681/// Where a response landed, if that is not `requested`.
682///
683/// Covers both halves of the redirect question at once, so the answer does
684/// not depend on how the caller's [`reqwest::Client`] is configured: a client
685/// that follows redirects arrives with a different [`reqwest::Response::url`],
686/// and one that does not arrives with a 3xx status still sitting at the
687/// requested URL. Either way the document did not come from `client_id`.
688fn redirected_away_from(requested: &reqwest::Url, response: &reqwest::Response) -> Option<String> {
689    if response.url() != requested {
690        return Some(response.url().to_string());
691    }
692    if !response.status().is_redirection() {
693        return None;
694    }
695    let location = response
696        .headers()
697        .get(reqwest::header::LOCATION)
698        .and_then(|value| value.to_str().ok())
699        .map(str::to_owned);
700    Some(location.unwrap_or_else(|| response.status().to_string()))
701}
702
703/// Reads at most [`MAX_DOCUMENT_BYTES`] of a response body.
704///
705/// A declared `Content-Length` over the bound is refused without reading
706/// anything; a body that lies about its length, or declines to declare one,
707/// is refused the moment it crosses the bound. Neither path ever holds more
708/// than one chunk past [`MAX_DOCUMENT_BYTES`] in memory.
709async fn read_bounded(
710    client_id: &str,
711    mut response: reqwest::Response,
712) -> Result<Vec<u8>, ClientMetadataError> {
713    if response
714        .content_length()
715        .is_some_and(|len| len > MAX_DOCUMENT_BYTES as u64)
716    {
717        return Err(ClientMetadataError::TooLarge(client_id.to_owned()));
718    }
719    let mut body = Vec::new();
720    while let Some(chunk) = response
721        .chunk()
722        .await
723        .map_err(|err| ClientMetadataError::Fetch(client_id.to_owned(), err.to_string()))?
724    {
725        if body.len() + chunk.len() > MAX_DOCUMENT_BYTES {
726            tracing::debug!(
727                client_id,
728                max_bytes = MAX_DOCUMENT_BYTES,
729                "abandoning an oversized client metadata document mid-body"
730            );
731            return Err(ClientMetadataError::TooLarge(client_id.to_owned()));
732        }
733        body.extend_from_slice(&chunk);
734    }
735    Ok(body)
736}
737
738/// Fetches, validates, and caches the client metadata document for
739/// `client_id`.
740///
741/// A cache hit skips the network entirely. A miss fetches over `http`,
742/// validates the result, and stores it — keyed by the URL, carrying the
743/// content key — before returning it. A confidential client's `jwks_uri` is
744/// fetched on the same miss, under the same bounds, and cached with the
745/// document, so its keys are read again whenever the document is.
746///
747/// `client_id` is attacker-chosen on the one endpoint that reaches this
748/// ([`crate::oauth::par`], which is unauthenticated), so the fetch is bounded
749/// here rather than left to whichever client is passed: the host must
750/// resolve to a public address ([`didbot_http::REFUSED_BLOCKS`]) and the
751/// fetch connects to the addresses that check approved,
752/// [`FETCH_TIMEOUT`] is a
753/// per-request total deadline, [`MAX_DOCUMENT_BYTES`] bounds the body, no
754/// redirect is followed at all — see [`ClientMetadataError::Redirected`] —
755/// and only a `200 OK` served as `application/json` is read.
756/// `http` makes the fetch only on a deployment that reaches its own machine
757/// ([`LoopbackClients::Fetched`]); every other one fetches through
758/// `pinned_public_client`.
759///
760/// Every variant this returns reaches `POST /oauth/par`'s caller as one
761/// sentence that names none of them — see
762/// [`crate::oauth::par::ParError::ClientMetadata`] — so the distinctions
763/// here are for this deployment's own log.
764pub async fn resolve_client(
765    http: &reqwest::Client,
766    cache: &dyn ClientMetadataCache,
767    client_id: &str,
768    loopback: LoopbackClients,
769) -> Result<(ClientMetadataDocument, ClientKey), ClientMetadataError> {
770    if let Some(built) = parse_loopback_client_id(client_id) {
771        let document = validate(client_id, built?)?;
772        let key = ClientKey::compute(&document);
773        return Ok((document, key));
774    }
775    if let Some(cached) = cache.get(client_id) {
776        return Ok((cached.document, cached.key));
777    }
778    let url = validate_url_scheme(client_id)
779        .map_err(|()| ClientMetadataError::NotHttps(client_id.to_owned()))?;
780    // Before the fetch, and answering exactly what a non-HTTPS `client_id`
781    // answers: this deployment does not reach loopback on a caller's say-so.
782    if url.scheme() == "http" && loopback == LoopbackClients::Refused {
783        return Err(ClientMetadataError::NotHttps(client_id.to_owned()));
784    }
785    if url.port().is_some() {
786        return Err(ClientMetadataError::HasPort(client_id.to_owned()));
787    }
788    if url.fragment().is_some() {
789        return Err(ClientMetadataError::HasFragment(client_id.to_owned()));
790    }
791    let body = fetch_bounded(http, client_id, &url, loopback, true).await?;
792    let raw: ClientMetadataDocument = serde_json::from_slice(&body)
793        .map_err(|err| ClientMetadataError::Malformed(client_id.to_owned(), err.to_string()))?;
794    let mut document = validate(client_id, raw)?;
795    if document.authentication.is_confidential() {
796        if let Some(jwks_uri) = document.authentication.jwks_uri.clone() {
797            document.authentication.jwks_uri_keys =
798                Some(fetch_jwks(http, client_id, &jwks_uri, loopback).await?);
799        }
800    }
801    let key = ClientKey::compute(&document);
802    cache.put(
803        client_id,
804        CachedClientMetadata {
805            document: document.clone(),
806            key: key.clone(),
807            fetched_at: OffsetDateTime::now_utc(),
808        },
809    );
810    Ok((document, key))
811}
812
813/// GETs `url` on `client_id`'s behalf, under every bound [`resolve_client`]
814/// names: [`FETCH_TIMEOUT`] for the whole fetch, [`MAX_DOCUMENT_BYTES`] for
815/// the body, and no redirect. Only a `200 OK` is read, and when `json_only`,
816/// only one served as `application/json`.
817///
818/// On a deployment that reaches its own machine the fetch goes through
819/// `http`, to the port [`didbot_identity::did::RESOLVE_PORTS`] names for a
820/// loopback host when `url` names none. Every other deployment fetches
821/// through a client pinned to the addresses `url`'s host resolves to,
822/// refused unless every one of them is public. That is the same gate the
823/// loopback carve-out is on, and deliberately so:
824/// `LoopbackClients::for_zone` is the one place that decides which
825/// deployments may reach their own machine.
826async fn fetch_bounded(
827    http: &reqwest::Client,
828    client_id: &str,
829    url: &reqwest::Url,
830    loopback: LoopbackClients,
831    json_only: bool,
832) -> Result<Vec<u8>, ClientMetadataError> {
833    let pinned;
834    let mut url = url.clone();
835    let http = match loopback {
836        LoopbackClients::Fetched => {
837            let named = url.host_str().and_then(didbot_identity::did::resolve_port);
838            if let (None, Some(port)) = (url.port(), named) {
839                tracing::debug!(client_id, %url, port, "fetching on the port RESOLVE_PORTS names");
840                // Fails only for a URL with no host, and this one has one.
841                let _ = url.set_port(Some(port));
842            }
843            http
844        }
845        LoopbackClients::Refused => {
846            pinned = pinned_public_client(client_id, &url).await?;
847            &pinned
848        }
849    };
850    let response = http
851        .get(url.clone())
852        .timeout(FETCH_TIMEOUT)
853        .send()
854        .await
855        .map_err(|err| ClientMetadataError::Fetch(client_id.to_owned(), err.to_string()))?;
856    if let Some(to) = redirected_away_from(&url, &response) {
857        tracing::debug!(
858            client_id,
859            %url,
860            %to,
861            "refusing client metadata served from somewhere other than the URL fetched"
862        );
863        return Err(ClientMetadataError::Redirected {
864            client_id: client_id.to_owned(),
865            to,
866        });
867    }
868    if response.status() != reqwest::StatusCode::OK {
869        return Err(ClientMetadataError::NotOk {
870            client_id: client_id.to_owned(),
871            status: response.status(),
872        });
873    }
874    if json_only {
875        let served = response
876            .headers()
877            .get(reqwest::header::CONTENT_TYPE)
878            .and_then(|value| value.to_str().ok())
879            .unwrap_or_default();
880        let matches = served
881            .parse::<mime::Mime>()
882            .is_ok_and(|served| served.essence_str() == mime::APPLICATION_JSON.essence_str());
883        if !matches {
884            return Err(ClientMetadataError::NotJson {
885                client_id: client_id.to_owned(),
886                content_type: served.to_owned(),
887            });
888        }
889    }
890    read_bounded(client_id, response).await
891}
892
893/// The key set a confidential client publishes at `jwks_uri`, fetched the way
894/// its document is ([`fetch_bounded`]).
895async fn fetch_jwks(
896    http: &reqwest::Client,
897    client_id: &str,
898    jwks_uri: &str,
899    loopback: LoopbackClients,
900) -> Result<Jwks, ClientMetadataError> {
901    let url = validate_url_scheme(jwks_uri)
902        .map_err(|()| ClientMetadataError::BadJwksUri(jwks_uri.to_owned()))?;
903    // Before the fetch, as for a `client_id`: this deployment does not reach
904    // loopback on a caller's say-so.
905    if url.scheme() == "http" && loopback == LoopbackClients::Refused {
906        return Err(ClientMetadataError::BadJwksUri(jwks_uri.to_owned()));
907    }
908    let body = fetch_bounded(http, client_id, &url, loopback, false).await?;
909    let keys: Jwks = serde_json::from_slice(&body)
910        .map_err(|err| ClientMetadataError::Malformed(jwks_uri.to_owned(), err.to_string()))?;
911    if keys.is_empty() {
912        return Err(ClientMetadataError::NoClientKeys(client_id.to_owned()));
913    }
914    Ok(keys)
915}
916
917/// `crate::oauth::par`'s own tests need a stand-in `client_id` host too, and
918/// this module already has one.
919#[cfg(test)]
920pub(crate) use tests::{
921    client_host as tests_client_host, reaching as tests_reaching, HOST as TESTS_HOST,
922};
923
924#[cfg(test)]
925mod tests {
926    use axum::response::{IntoResponse, Redirect};
927    use axum::routing::get;
928    use axum::Router;
929    use tokio::io::AsyncWriteExt;
930    use tokio::net::TcpListener;
931
932    use super::*;
933
934    fn doc(client_id: &str, redirect_uris: Vec<&str>) -> ClientMetadataDocument {
935        ClientMetadataDocument {
936            client_id: client_id.to_owned(),
937            application_type: ApplicationType::Web,
938            client_name: Some("Placeholder Zydeco".to_owned()),
939            client_uri: None,
940            logo_uri: None,
941            redirect_uris: redirect_uris.into_iter().map(str::to_owned).collect(),
942            grant_types: vec![],
943            response_types: vec![],
944            authentication: ClientAuthentication::public(),
945            scope: None,
946            dpop_bound_access_tokens: true,
947        }
948    }
949
950    #[test]
951    fn validate_defaults_grant_and_response_types() {
952        let d = validate(
953            "https://client.example/id.json",
954            doc(
955                "https://client.example/id.json",
956                vec!["https://client.example/cb"],
957            ),
958        )
959        .unwrap();
960        assert_eq!(d.grant_types, vec!["authorization_code"]);
961        assert_eq!(d.response_types, vec!["code"]);
962    }
963
964    #[test]
965    fn client_id_must_match_the_document() {
966        let err = validate(
967            "https://client.example/id.json",
968            doc(
969                "https://someone-else.example/id.json",
970                vec!["https://client.example/cb"],
971            ),
972        )
973        .unwrap_err();
974        assert!(matches!(err, ClientMetadataError::ClientIdMismatch { .. }));
975    }
976
977    #[test]
978    fn empty_redirect_uris_is_refused() {
979        let err = validate(
980            "https://client.example/id.json",
981            doc("https://client.example/id.json", vec![]),
982        )
983        .unwrap_err();
984        assert!(matches!(err, ClientMetadataError::NoRedirectUris(_)));
985    }
986
987    /// A web client's redirect is `https://`, on any origin and any port.
988    /// A loopback `http://` redirect is a native client's, so a web client
989    /// may not register one either.
990    #[test]
991    fn a_web_client_registers_only_https_redirects() {
992        let client_id = "https://client.example/id.json";
993        let web = |redirect_uri: &str| validate(client_id, doc(client_id, vec![redirect_uri]));
994        for admitted in [
995            "https://client.example/cb",
996            "https://elsewhere.example:8443/cb",
997        ] {
998            assert!(web(admitted).is_ok(), "{admitted}");
999        }
1000        for refused in [
1001            "http://evil.example/cb",
1002            "http://127.0.0.1/cb",
1003            "http://[::1]:4000/cb",
1004            "http://localhost:4000/cb",
1005            "https://client.example/cb#done",
1006        ] {
1007            assert_eq!(
1008                web(refused).unwrap_err(),
1009                ClientMetadataError::BadRedirectUri(refused.to_owned()),
1010                "{refused}"
1011            );
1012        }
1013    }
1014
1015    #[test]
1016    fn loopback_client_id_with_redirect_uri_and_scope_builds_its_own_document() {
1017        let client_id =
1018            "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2Fcb&scope=atproto+transition%3Ageneric";
1019        let document = parse_loopback_client_id(client_id)
1020            .expect("recognised as the loopback form")
1021            .expect("builds");
1022        assert_eq!(document.client_id, client_id);
1023        assert_eq!(document.redirect_uris, vec!["http://127.0.0.1:5555/cb"]);
1024        assert_eq!(
1025            document.scope.as_deref(),
1026            Some("atproto transition:generic")
1027        );
1028        assert_eq!(
1029            document.grant_types,
1030            vec!["authorization_code", "refresh_token"]
1031        );
1032        assert_eq!(document.response_types, vec!["code"]);
1033        assert_eq!(document.authentication.token_endpoint_auth_method, "none");
1034        assert!(document.dpop_bound_access_tokens);
1035        assert_eq!(document.application_type, ApplicationType::Native);
1036    }
1037
1038    /// The atproto profile's rules for a native client's redirects, each
1039    /// refused on its own.
1040    #[test]
1041    fn a_native_client_registers_only_the_redirects_the_profile_allows() {
1042        let client_id = "https://app.example.com/oauth-client-metadata.json";
1043        let native = |redirect: &str| {
1044            validate(
1045                client_id,
1046                ClientMetadataDocument {
1047                    application_type: ApplicationType::Native,
1048                    ..doc(client_id, vec![redirect])
1049                },
1050            )
1051        };
1052        for admitted in [
1053            "com.example.app:/callback",
1054            "com.example.app:/",
1055            "https://app.example.com/callback",
1056            "http://127.0.0.1:4000/callback",
1057            "http://[::1]/callback",
1058        ] {
1059            assert!(native(admitted).is_ok(), "{admitted}");
1060        }
1061        for refused in [
1062            // Not the client_id's host in reverse-domain order.
1063            "com.example.other:/callback",
1064            "app.example.com:/callback",
1065            // Not a single slash after the scheme.
1066            "com.example.app://callback",
1067            "com.example.app:///callback",
1068            "com.example.app:callback",
1069            "com.example.app:/callback#done",
1070            // Not the client_id's origin.
1071            "https://other.example.com/callback",
1072            "https://app.example.com:8443/callback",
1073            // Not a loopback address.
1074            "http://localhost:4000/callback",
1075            "http://app.example.com/callback",
1076        ] {
1077            assert_eq!(
1078                native(refused).unwrap_err(),
1079                ClientMetadataError::BadNativeRedirectUri(refused.to_owned()),
1080                "{refused}"
1081            );
1082        }
1083    }
1084
1085    /// A custom scheme is a native client's, so a web client may not
1086    /// register one.
1087    #[test]
1088    fn a_web_client_may_not_register_a_custom_scheme() {
1089        let client_id = "https://app.example.com/oauth-client-metadata.json";
1090        let err =
1091            validate(client_id, doc(client_id, vec!["com.example.app:/callback"])).unwrap_err();
1092        assert_eq!(
1093            err,
1094            ClientMetadataError::BadRedirectUri("com.example.app:/callback".to_owned())
1095        );
1096    }
1097
1098    /// `application_type` is `web` or `native`, and `web` when absent. A
1099    /// document saying `web` is the same document as one saying nothing.
1100    #[test]
1101    fn application_type_is_web_native_or_absent() {
1102        let read = |application_type: Option<&str>| {
1103            let mut document = serde_json::json!({
1104                "client_id": "https://app.example.com/oauth-client-metadata.json",
1105                "redirect_uris": ["https://app.example.com/callback"],
1106                "grant_types": ["authorization_code"],
1107                "response_types": ["code"],
1108                "token_endpoint_auth_method": "none",
1109                "dpop_bound_access_tokens": true,
1110            });
1111            if let Some(application_type) = application_type {
1112                document["application_type"] = application_type.into();
1113            }
1114            serde_json::from_value::<ClientMetadataDocument>(document)
1115        };
1116        let absent = read(None).expect("absent reads");
1117        let web = read(Some("web")).expect("web reads");
1118        let native = read(Some("native")).expect("native reads");
1119        assert_eq!(absent.application_type, ApplicationType::Web);
1120        assert_eq!(ClientKey::compute(&absent), ClientKey::compute(&web));
1121        assert_eq!(native.application_type, ApplicationType::Native);
1122        assert_ne!(ClientKey::compute(&absent), ClientKey::compute(&native));
1123        assert!(read(Some("desktop")).is_err());
1124    }
1125
1126    #[test]
1127    fn loopback_client_id_with_neither_param_gets_the_spec_defaults() {
1128        let document = parse_loopback_client_id("http://localhost")
1129            .expect("recognised as the loopback form")
1130            .expect("builds");
1131        assert_eq!(
1132            document.redirect_uris,
1133            vec!["http://127.0.0.1/", "http://[::1]/"]
1134        );
1135        assert_eq!(document.scope.as_deref(), Some("atproto"));
1136    }
1137
1138    /// A loopback redirect matches on every part but the port, which a
1139    /// native client learns only when it starts listening. Everything else
1140    /// matches exactly.
1141    #[test]
1142    fn a_loopback_redirect_matches_on_every_part_but_the_port() {
1143        let defaults = parse_loopback_client_id("http://localhost")
1144            .expect("recognised as the loopback form")
1145            .expect("builds");
1146        for admitted in [
1147            "http://127.0.0.1:61023/",
1148            "http://[::1]:61023/",
1149            // jacquard-oauth's spelling of the first default.
1150            "http://127.0.0.1",
1151        ] {
1152            assert!(defaults.admits_redirect_uri(admitted), "{admitted}");
1153        }
1154        for refused in [
1155            "http://127.0.0.1:61023/elsewhere",
1156            "http://127.0.0.1:61023/?next=elsewhere",
1157            "https://127.0.0.1:61023/",
1158            "http://localhost:61023/",
1159            "http://127.0.0.2:61023/",
1160            "http://user@127.0.0.1:61023/",
1161        ] {
1162            assert!(!defaults.admits_redirect_uri(refused), "{refused}");
1163        }
1164
1165        // A registered port is no more binding than a missing one.
1166        let named = parse_loopback_client_id(
1167            "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A4000%2Fcallback",
1168        )
1169        .expect("recognised as the loopback form")
1170        .expect("builds");
1171        assert!(named.admits_redirect_uri("http://127.0.0.1:5000/callback"));
1172
1173        // A web client's redirect is matched exactly, port and all.
1174        let web = doc(
1175            "https://client.example/id.json",
1176            vec!["https://client.example/cb"],
1177        );
1178        assert!(web.admits_redirect_uri("https://client.example/cb"));
1179        assert!(!web.admits_redirect_uri("https://client.example:8443/cb"));
1180        assert!(!web.admits_redirect_uri("https://client.example/cb/"));
1181    }
1182
1183    #[test]
1184    fn loopback_client_id_with_a_trailing_slash_is_still_recognised() {
1185        let document = parse_loopback_client_id("http://localhost/")
1186            .expect("recognised as the loopback form")
1187            .expect("builds");
1188        assert_eq!(
1189            document.redirect_uris,
1190            vec!["http://127.0.0.1/", "http://[::1]/"]
1191        );
1192    }
1193
1194    #[test]
1195    fn loopback_client_ids_redirect_uri_on_localhost_is_refused() {
1196        let err = parse_loopback_client_id(
1197            "http://localhost/?redirect_uri=http%3A%2F%2Flocalhost%3A5555%2Fcb",
1198        )
1199        .expect("recognised as the loopback form")
1200        .expect_err("localhost is not a usable loopback redirect_uri");
1201        assert!(matches!(err, ClientMetadataError::LoopbackRedirectUri(_)));
1202    }
1203
1204    #[test]
1205    fn a_port_on_the_client_id_is_not_the_loopback_form() {
1206        // `http://localhost:4000/...` is the other, fetchable development
1207        // carve-out (see `loopback_redirect_uri_is_allowed_for_development`
1208        // above) — the port alone must be enough to keep this branch from
1209        // claiming it.
1210        assert!(parse_loopback_client_id(
1211            "http://localhost:4000/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb"
1212        )
1213        .is_none());
1214    }
1215
1216    #[test]
1217    fn a_deeper_path_on_localhost_is_not_the_loopback_form() {
1218        assert!(parse_loopback_client_id("http://localhost/id.json").is_none());
1219    }
1220
1221    #[test]
1222    fn loopback_clients_with_different_scopes_get_different_keys() {
1223        let a = parse_loopback_client_id("http://localhost/?scope=atproto")
1224            .expect("recognised")
1225            .expect("builds");
1226        let b = parse_loopback_client_id("http://localhost/?scope=atproto+transition%3Ageneric")
1227            .expect("recognised")
1228            .expect("builds");
1229        assert_ne!(ClientKey::compute(&a), ClientKey::compute(&b));
1230    }
1231
1232    #[test]
1233    fn not_dpop_bound_is_refused() {
1234        let mut document = doc(
1235            "https://client.example/id.json",
1236            vec!["https://client.example/cb"],
1237        );
1238        document.dpop_bound_access_tokens = false;
1239        let err = validate("https://client.example/id.json", document).unwrap_err();
1240        assert!(matches!(err, ClientMetadataError::NotDpopBound(_)));
1241    }
1242
1243    #[test]
1244    fn changing_redirect_uris_changes_the_client_key() {
1245        let a = validate(
1246            "https://client.example/id.json",
1247            doc(
1248                "https://client.example/id.json",
1249                vec!["https://client.example/cb"],
1250            ),
1251        )
1252        .unwrap();
1253        let b = validate(
1254            "https://client.example/id.json",
1255            doc(
1256                "https://client.example/id.json",
1257                vec!["https://client.example/new-cb"],
1258            ),
1259        )
1260        .unwrap();
1261        assert_ne!(ClientKey::compute(&a), ClientKey::compute(&b));
1262    }
1263
1264    #[test]
1265    fn the_same_document_content_is_the_same_key_every_time() {
1266        let a = doc(
1267            "https://client.example/id.json",
1268            vec!["https://client.example/cb"],
1269        );
1270        let b = a.clone();
1271        assert_eq!(ClientKey::compute(&a), ClientKey::compute(&b));
1272    }
1273
1274    #[test]
1275    fn memory_cache_expires_after_ttl() {
1276        let cache = MemoryClientMetadataCache::new();
1277        let document = validate(
1278            "https://client.example/id.json",
1279            doc(
1280                "https://client.example/id.json",
1281                vec!["https://client.example/cb"],
1282            ),
1283        )
1284        .unwrap();
1285        let key = ClientKey::compute(&document);
1286        cache.put(
1287            "https://client.example/id.json",
1288            CachedClientMetadata {
1289                document,
1290                key,
1291                fetched_at: OffsetDateTime::now_utc() - CACHE_TTL - Duration::seconds(1),
1292            },
1293        );
1294        assert!(cache.get("https://client.example/id.json").is_none());
1295    }
1296
1297    /// A metadata document, as JSON, claiming `client_id`.
1298    fn document_json(client_id: &str) -> String {
1299        serde_json::json!({
1300            "client_id": client_id,
1301            "redirect_uris": ["https://app.example/cb"],
1302            "grant_types": ["authorization_code"],
1303            "response_types": ["code"],
1304            "token_endpoint_auth_method": "none",
1305            "dpop_bound_access_tokens": true,
1306        })
1307        .to_string()
1308    }
1309
1310    /// Where every stand-in `client_id` host in these tests is named. A
1311    /// `client_id` may not carry a port, so each test reaches its host
1312    /// through [`reaching`].
1313    pub(crate) const HOST: &str = "http://localhost";
1314
1315    /// [`crate::oauth::client_metadata_http_client`], but reaching `addr` for
1316    /// [`HOST`], and following redirects only if `redirects` says so. This
1317    /// is how a `.localhost` deployment reaches a client that
1318    /// [`didbot_identity::did::RESOLVE_PORTS`] names, without setting this
1319    /// process's environment.
1320    pub(crate) fn reaching(
1321        addr: std::net::SocketAddr,
1322        redirects: reqwest::redirect::Policy,
1323    ) -> reqwest::Client {
1324        didbot_http::builder()
1325            .timeout(FETCH_TIMEOUT)
1326            .redirect(redirects)
1327            .resolve("localhost", addr)
1328            .build()
1329            .expect("a client")
1330    }
1331
1332    /// [`reaching`] `addr`, following no redirect, as a deployment's own
1333    /// client does not.
1334    fn reaching_only(addr: std::net::SocketAddr) -> reqwest::Client {
1335        reaching(addr, reqwest::redirect::Policy::none())
1336    }
1337
1338    /// A loopback server standing in for [`HOST`], serving four documents:
1339    /// one well-behaved, one that redirects, one the redirect lands on that
1340    /// *claims the redirecting URL's own `client_id`* (so a server that
1341    /// followed the redirect would accept it), and one that is far too large
1342    /// with an honest `Content-Length`.
1343    ///
1344    /// Returns the address it listens on. Every body it serves is bounded —
1345    /// proving a cap is applied does not require serving anything near a
1346    /// real flood.
1347    pub(crate) async fn client_host() -> std::net::SocketAddr {
1348        let listener = TcpListener::bind("127.0.0.1:0")
1349            .await
1350            .expect("loopback binds");
1351        let addr = listener.local_addr().expect("bound listener has an addr");
1352        let good = document_json(&format!("{HOST}/id.json"));
1353        // The document the redirect lands on impersonates the URL that
1354        // redirected: `validate` compares against the *requested*
1355        // `client_id`, so nothing downstream would catch this.
1356        let impersonating = document_json(&format!("{HOST}/moved.json"));
1357        let oversized = "x".repeat(MAX_DOCUMENT_BYTES + 1);
1358        let app = Router::new()
1359            .route("/id.json", get(move || async move { json(good) }))
1360            .route(
1361                "/moved.json",
1362                get(|| async { Redirect::temporary("/elsewhere.json") }),
1363            )
1364            .route(
1365                "/elsewhere.json",
1366                get(move || async move { json(impersonating) }),
1367            )
1368            .route("/huge.json", get(move || async move { json(oversized) }));
1369        tokio::spawn(async move {
1370            axum::serve(listener, app)
1371                .await
1372                .expect("client host serves");
1373        });
1374        addr
1375    }
1376
1377    /// One `application/json` response body.
1378    fn json(body: String) -> axum::response::Response {
1379        (
1380            [(axum::http::header::CONTENT_TYPE, "application/json")],
1381            body,
1382        )
1383            .into_response()
1384    }
1385
1386    /// A loopback server that answers any request with a chunked body and no
1387    /// `Content-Length`, so the only thing that can stop it is the streaming
1388    /// half of [`read_bounded`]. Written by hand rather than through `axum`
1389    /// so the response genuinely declares no length.
1390    ///
1391    /// It writes five sixteen-kibibyte chunks and stops — enough to cross
1392    /// [`MAX_DOCUMENT_BYTES`], and nothing like a real endless stream.
1393    async fn chunked_client_host() -> std::net::SocketAddr {
1394        let listener = TcpListener::bind("127.0.0.1:0")
1395            .await
1396            .expect("loopback binds");
1397        let addr = listener.local_addr().expect("bound listener has an addr");
1398        tokio::spawn(async move {
1399            while let Ok((mut socket, _)) = listener.accept().await {
1400                tokio::spawn(async move {
1401                    // The request is irrelevant; answer without reading it.
1402                    let head = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
1403                                Transfer-Encoding: chunked\r\n\r\n";
1404                    if socket.write_all(head.as_bytes()).await.is_err() {
1405                        return;
1406                    }
1407                    let payload = "x".repeat(16 * 1024);
1408                    for _ in 0..5 {
1409                        let chunk = format!("{:x}\r\n{payload}\r\n", payload.len());
1410                        // A broken pipe here is the success case: the reader
1411                        // gave up partway, which is the point of the test.
1412                        if socket.write_all(chunk.as_bytes()).await.is_err() {
1413                            return;
1414                        }
1415                    }
1416                    let _ = socket.write_all(b"0\r\n\r\n").await;
1417                });
1418            }
1419        });
1420        addr
1421    }
1422
1423    /// A `client_id` host that accepts the connection and never answers
1424    /// costs `/oauth/par` exactly [`FETCH_TIMEOUT`], then a fetch error.
1425    #[tokio::test(start_paused = true)]
1426    async fn a_client_id_host_that_never_answers_is_a_fetch_error_after_the_fetch_timeout() {
1427        let addr = didbot_http::test_support::hung_server();
1428        let cache = MemoryClientMetadataCache::new();
1429        let client = reaching_only(addr);
1430        let client_id = format!("{HOST}/client-metadata.json");
1431        let started = tokio::time::Instant::now();
1432        let resolving = resolve_client(&client, &cache, &client_id, LoopbackClients::Fetched);
1433        let err = tokio::time::timeout(FETCH_TIMEOUT * 2, resolving)
1434            .await
1435            .expect("the fetch's own timeout fires first")
1436            .expect_err("nothing ever answers");
1437        assert!(matches!(err, ClientMetadataError::Fetch(..)), "{err:?}");
1438        assert_eq!(started.elapsed(), FETCH_TIMEOUT);
1439    }
1440
1441    #[tokio::test]
1442    async fn resolve_client_recognises_the_loopback_form_without_any_network() {
1443        let cache = MemoryClientMetadataCache::new();
1444        // No listener bound anywhere; a network call here would time out and
1445        // fail the test. The loopback form is resolved from the query
1446        // alone.
1447        let (document, key) = resolve_client(
1448            &crate::oauth::client_metadata_http_client(),
1449            &cache,
1450            "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A9%2Fcb&scope=atproto",
1451            LoopbackClients::Fetched,
1452        )
1453        .await
1454        .expect("the loopback form resolves without a fetch");
1455        assert_eq!(document.redirect_uris, vec!["http://127.0.0.1:9/cb"]);
1456        assert_eq!(key, ClientKey::compute(&document));
1457    }
1458
1459    #[tokio::test]
1460    async fn a_bare_loopback_client_id_resolves_with_both_default_redirect_uris() {
1461        let cache = MemoryClientMetadataCache::new();
1462        let (document, _key) = resolve_client(
1463            &crate::oauth::client_metadata_http_client(),
1464            &cache,
1465            "http://localhost",
1466            LoopbackClients::Fetched,
1467        )
1468        .await
1469        .expect("a client_id that spells no redirect_uri resolves on the spec defaults");
1470        assert_eq!(
1471            document.redirect_uris,
1472            vec!["http://127.0.0.1/", "http://[::1]/"]
1473        );
1474    }
1475
1476    /// A `client_id` may not name a port, whichever deployment is asked,
1477    /// and it is refused before anything is fetched: the host accepts and
1478    /// never answers, so a fetch would cost [`FETCH_TIMEOUT`]. The loopback
1479    /// form is `http://localhost` with no port, so `localhost` with one is
1480    /// judged like any other `client_id`.
1481    #[tokio::test(start_paused = true)]
1482    async fn a_client_id_that_names_a_port_is_refused_before_the_fetch() {
1483        let addr = didbot_http::test_support::hung_server();
1484        let cache = MemoryClientMetadataCache::new();
1485        let started = tokio::time::Instant::now();
1486        for client_id in [
1487            format!("http://localhost:{}/id.json", addr.port()),
1488            format!("http://localhost:{}", addr.port()),
1489            format!("https://localhost:{}/id.json", addr.port()),
1490            "https://app.example:8443/id.json".to_owned(),
1491        ] {
1492            for loopback in [LoopbackClients::Fetched, LoopbackClients::Refused] {
1493                let err = resolve_client(&reaching_only(addr), &cache, &client_id, loopback)
1494                    .await
1495                    .expect_err("a client_id with a port is refused");
1496                let refused = match loopback {
1497                    // Off a `.localhost` zone, loopback `http://` is refused
1498                    // first, as it is with no port.
1499                    LoopbackClients::Refused if client_id.starts_with("http:") => {
1500                        ClientMetadataError::NotHttps(client_id.clone())
1501                    }
1502                    _ => ClientMetadataError::HasPort(client_id.clone()),
1503                };
1504                assert_eq!(err, refused, "{client_id} {loopback:?}");
1505            }
1506        }
1507        assert_eq!(started.elapsed(), std::time::Duration::ZERO);
1508    }
1509
1510    #[tokio::test]
1511    async fn a_well_formed_document_still_resolves_through_the_bounded_read() {
1512        let addr = client_host().await;
1513        let cache = MemoryClientMetadataCache::new();
1514        let client_id = format!("{HOST}/id.json");
1515        let (document, _key) = resolve_client(
1516            &reaching_only(addr),
1517            &cache,
1518            &client_id,
1519            LoopbackClients::Fetched,
1520        )
1521        .await
1522        .expect("an ordinary document resolves");
1523        assert_eq!(document.client_id, client_id);
1524    }
1525
1526    /// The client [`crate::oauth::client_metadata_http_client`] builds does
1527    /// not follow redirects, so the 3xx itself arrives — and is refused.
1528    #[tokio::test]
1529    async fn a_redirecting_client_id_is_refused_when_the_client_does_not_follow() {
1530        let addr = client_host().await;
1531        let cache = MemoryClientMetadataCache::new();
1532        let err = resolve_client(
1533            &reaching_only(addr),
1534            &cache,
1535            &format!("{HOST}/moved.json"),
1536            LoopbackClients::Fetched,
1537        )
1538        .await
1539        .expect_err("a redirect is not a client metadata document");
1540        assert!(
1541            matches!(err, ClientMetadataError::Redirected { .. }),
1542            "expected Redirected, got {err:?}"
1543        );
1544    }
1545
1546    /// And a client that *does* follow — the `reqwest` default a deployment
1547    /// might wire in — lands somewhere else and is refused there. Without
1548    /// this check the impersonating document `/elsewhere.json` serves would
1549    /// be accepted: it claims exactly the `client_id` that was requested.
1550    #[tokio::test]
1551    async fn a_followed_redirect_is_refused_at_the_url_it_landed_on() {
1552        let addr = client_host().await;
1553        let cache = MemoryClientMetadataCache::new();
1554        let err = resolve_client(
1555            &reaching(addr, reqwest::redirect::Policy::default()),
1556            &cache,
1557            &format!("{HOST}/moved.json"),
1558            LoopbackClients::Fetched,
1559        )
1560        .await
1561        .expect_err("the document did not come from the client_id");
1562        match err {
1563            ClientMetadataError::Redirected { to, .. } => {
1564                assert!(to.ends_with("/elsewhere.json"), "landed on {to}");
1565            }
1566            other => panic!("expected Redirected, got {other:?}"),
1567        }
1568    }
1569
1570    /// The profile has a document answered with "HTTP status must be 200
1571    /// (not another 2xx or a redirect)" and "the correct Content-Type
1572    /// (application/json)". A document that is otherwise well formed is
1573    /// refused on either.
1574    #[tokio::test]
1575    async fn only_a_200_served_as_json_is_read() {
1576        let listener = TcpListener::bind("127.0.0.1:0")
1577            .await
1578            .expect("loopback binds");
1579        let addr = listener.local_addr().expect("bound listener has an addr");
1580        // Every path serves a document claiming its own URL, answered as the
1581        // path's name says.
1582        let app = Router::new().fallback(|uri: axum::http::Uri| async move {
1583            let (status, content_type) = match uri.path() {
1584                "/created.json" => (201, Some("application/json")),
1585                "/missing.json" => (404, Some("application/json")),
1586                "/text.json" => (200, Some("text/plain")),
1587                "/ld.json" => (200, Some("application/ld+json")),
1588                "/untyped.json" => (200, None),
1589                "/charset.json" => (200, Some("application/json; charset=utf-8")),
1590                _ => (200, Some("application/json")),
1591            };
1592            let mut response = axum::response::Response::builder().status(status);
1593            if let Some(content_type) = content_type {
1594                response = response.header(axum::http::header::CONTENT_TYPE, content_type);
1595            }
1596            response
1597                .body(axum::body::Body::from(document_json(&format!(
1598                    "{HOST}{}",
1599                    uri.path()
1600                ))))
1601                .expect("a response")
1602        });
1603        tokio::spawn(async move {
1604            axum::serve(listener, app)
1605                .await
1606                .expect("client host serves");
1607        });
1608
1609        let cache = MemoryClientMetadataCache::new();
1610        let resolve = |path: &str| {
1611            let client_id = format!("{HOST}{path}");
1612            let http = reaching_only(addr);
1613            let cache = &cache;
1614            async move {
1615                resolve_client(&http, cache, &client_id, LoopbackClients::Fetched)
1616                    .await
1617                    .map(|_| ())
1618            }
1619        };
1620        for read in ["/id.json", "/charset.json"] {
1621            assert_eq!(resolve(read).await, Ok(()), "{read}");
1622        }
1623        for (path, status) in [("/created.json", 201), ("/missing.json", 404)] {
1624            assert_eq!(
1625                resolve(path).await,
1626                Err(ClientMetadataError::NotOk {
1627                    client_id: format!("{HOST}{path}"),
1628                    status: reqwest::StatusCode::from_u16(status).expect("a status"),
1629                }),
1630                "{path}"
1631            );
1632        }
1633        for (path, content_type) in [
1634            ("/text.json", "text/plain"),
1635            ("/ld.json", "application/ld+json"),
1636            ("/untyped.json", ""),
1637        ] {
1638            assert_eq!(
1639                resolve(path).await,
1640                Err(ClientMetadataError::NotJson {
1641                    client_id: format!("{HOST}{path}"),
1642                    content_type: content_type.to_owned(),
1643                }),
1644                "{path}"
1645            );
1646        }
1647    }
1648
1649    #[tokio::test]
1650    async fn an_oversized_document_is_refused_on_its_declared_length() {
1651        let addr = client_host().await;
1652        let cache = MemoryClientMetadataCache::new();
1653        let err = resolve_client(
1654            &reaching_only(addr),
1655            &cache,
1656            &format!("{HOST}/huge.json"),
1657            LoopbackClients::Fetched,
1658        )
1659        .await
1660        .expect_err("a document over the cap is not read");
1661        assert!(
1662            matches!(err, ClientMetadataError::TooLarge(_)),
1663            "expected TooLarge, got {err:?}"
1664        );
1665    }
1666
1667    #[tokio::test]
1668    async fn an_oversized_document_that_declares_no_length_is_refused_mid_body() {
1669        let addr = chunked_client_host().await;
1670        let cache = MemoryClientMetadataCache::new();
1671        let err = resolve_client(
1672            &reaching_only(addr),
1673            &cache,
1674            &format!("{HOST}/id.json"),
1675            LoopbackClients::Fetched,
1676        )
1677        .await
1678        .expect_err("a body that outgrows the cap is abandoned");
1679        assert!(
1680            matches!(err, ClientMetadataError::TooLarge(_)),
1681            "expected TooLarge, got {err:?}"
1682        );
1683    }
1684
1685    /// **The port oracle this closes.** A `client_id` naming a loopback
1686    /// host makes this server fetch a port of the caller's choosing on the
1687    /// machine running it, and answer differently depending on what it
1688    /// found. `POST /oauth/par` is unauthenticated, so on a deployment that
1689    /// is not itself a development one that is a port scanner anyone can
1690    /// drive. Refused before the fetch, with the same answer a plain
1691    /// `http://` `client_id` gets, so nothing is learned from the wording
1692    /// either.
1693    #[tokio::test]
1694    async fn a_loopback_client_id_is_refused_before_the_fetch_off_a_localhost_zone() {
1695        let addr = client_host().await;
1696        let cache = MemoryClientMetadataCache::new();
1697        let client_id = format!("{HOST}/id.json");
1698        let err = resolve_client(
1699            &reaching_only(addr),
1700            &cache,
1701            &client_id,
1702            LoopbackClients::Refused,
1703        )
1704        .await
1705        .expect_err("a real deployment does not fetch loopback on a caller's say-so");
1706        assert!(matches!(err, ClientMetadataError::NotHttps(_)), "{err:?}");
1707        assert!(cache.get(&client_id).is_none());
1708
1709        // The `http://localhost` form fetches nothing, so it reaches no
1710        // port and every deployment builds it.
1711        let virtual_id =
1712            "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A9%2Fcb&scope=atproto";
1713        let (built, _key) = resolve_client(
1714            &reaching_only(addr),
1715            &cache,
1716            virtual_id,
1717            LoopbackClients::Refused,
1718        )
1719        .await
1720        .expect("a native client's virtual document needs no fetch");
1721        assert_eq!(built.redirect_uris, ["http://127.0.0.1:9/cb"]);
1722    }
1723
1724    /// And the check runs before the fetch, not after it: the `client_id` is
1725    /// `https://`, so the loopback carve-out never sees it, and nothing is
1726    /// sent to the address it names.
1727    #[tokio::test(start_paused = true)]
1728    async fn a_client_id_on_this_network_is_refused_before_the_fetch() {
1729        let cache = MemoryClientMetadataCache::new();
1730        let client_id = "https://127.0.0.1/client-metadata.json".to_owned();
1731        let started = tokio::time::Instant::now();
1732        let err = resolve_client(
1733            &crate::oauth::client_metadata_http_client(),
1734            &cache,
1735            &client_id,
1736            LoopbackClients::Refused,
1737        )
1738        .await
1739        .expect_err("a real deployment does not fetch from its own network");
1740        assert!(
1741            matches!(err, ClientMetadataError::RefusedAddress(_)),
1742            "{err:?}"
1743        );
1744        assert_eq!(started.elapsed(), std::time::Duration::ZERO);
1745        assert!(cache.get(&client_id).is_none());
1746    }
1747
1748    /// The `.localhost` development exception is the one thing that gets
1749    /// past it, and it is gated exactly where it already was: on the
1750    /// deployment's own zone, through [`LoopbackClients`].
1751    #[tokio::test]
1752    async fn a_localhost_zone_still_fetches_from_its_own_machine() {
1753        let addr = client_host().await;
1754        let cache = MemoryClientMetadataCache::new();
1755        let client_id = format!("{HOST}/id.json");
1756        let (document, _key) = resolve_client(
1757            &reaching_only(addr),
1758            &cache,
1759            &client_id,
1760            LoopbackClients::Fetched,
1761        )
1762        .await
1763        .expect("a development deployment reaches its own machine");
1764        assert_eq!(document.client_id, client_id);
1765    }
1766
1767    #[test]
1768    fn only_a_localhost_zone_fetches_loopback_clients() {
1769        for zone in [
1770            "localhost",
1771            "agents.localhost",
1772            "AGENTS.LOCALHOST",
1773            "a.b.localhost.",
1774        ] {
1775            assert_eq!(
1776                LoopbackClients::for_zone(zone),
1777                LoopbackClients::Fetched,
1778                "{zone}"
1779            );
1780        }
1781        for zone in ["pds.did.bot", "localhost.example", "example.com", ""] {
1782            assert_eq!(
1783                LoopbackClients::for_zone(zone),
1784                LoopbackClients::Refused,
1785                "{zone}"
1786            );
1787        }
1788    }
1789
1790    /// **The unbounded map this closes.** Every distinct `client_id` an
1791    /// unauthenticated caller invents was its own entry, in a map nothing
1792    /// ever removed from. Past [`CACHE_CAPACITY`] the entry held longest
1793    /// goes, so the footprint is the bound rather than the caller's
1794    /// imagination.
1795    #[test]
1796    fn the_cache_never_holds_more_than_its_capacity() {
1797        let cache = MemoryClientMetadataCache::new();
1798        let document = ClientMetadataDocument {
1799            client_id: "https://client.example/id.json".to_owned(),
1800            application_type: ApplicationType::Web,
1801            client_name: None,
1802            client_uri: None,
1803            logo_uri: None,
1804            redirect_uris: vec!["https://client.example/cb".to_owned()],
1805            grant_types: vec!["authorization_code".to_owned()],
1806            response_types: vec!["code".to_owned()],
1807            authentication: ClientAuthentication::public(),
1808            scope: Some("atproto".to_owned()),
1809            dpop_bound_access_tokens: true,
1810        };
1811        let key = ClientKey::compute(&document);
1812        let start = OffsetDateTime::now_utc();
1813        for n in 0..(CACHE_CAPACITY * 2) {
1814            cache.put(
1815                &format!("https://invented-{n}.example/id.json"),
1816                CachedClientMetadata {
1817                    document: document.clone(),
1818                    key: key.clone(),
1819                    // Distinct, increasing, so "held longest" is well defined.
1820                    fetched_at: start + Duration::seconds(n as i64),
1821                },
1822            );
1823        }
1824        assert_eq!(
1825            cache.entries.read().unwrap().len(),
1826            CACHE_CAPACITY,
1827            "the cache grew past its bound"
1828        );
1829        assert!(cache.get("https://invented-0.example/id.json").is_none());
1830        assert!(cache
1831            .get(&format!(
1832                "https://invented-{}.example/id.json",
1833                CACHE_CAPACITY * 2 - 1
1834            ))
1835            .is_some());
1836    }
1837
1838    /// Nothing is cached from a fetch that was refused — a refusal must not
1839    /// become a poisoned entry a later, legitimate fetch reads back.
1840    #[tokio::test]
1841    async fn a_refused_fetch_caches_nothing() {
1842        let addr = client_host().await;
1843        let cache = MemoryClientMetadataCache::new();
1844        let client_id = format!("{HOST}/moved.json");
1845        let _ = resolve_client(
1846            &reaching_only(addr),
1847            &cache,
1848            &client_id,
1849            LoopbackClients::Fetched,
1850        )
1851        .await;
1852        assert!(cache.get(&client_id).is_none());
1853    }
1854
1855    /// The public half of a fresh ES256 key named `kid`, as a client
1856    /// publishes it.
1857    fn published_keys(kid: &str) -> serde_json::Value {
1858        let keyset = jacquard_oauth::keyset::Keyset::generate_es256(kid).expect("a key");
1859        serde_json::to_value(keyset.public_jwks()).expect("a key set serializes")
1860    }
1861
1862    /// What the atproto profile refuses of a confidential client's
1863    /// document, each refused on its own. A public client may name none of
1864    /// it, but `none` as an algorithm is refused whoever names it.
1865    #[test]
1866    fn what_the_profile_refuses_of_client_authentication_is_refused() {
1867        let client_id = "https://client.example/id.json";
1868        let keys: Jwks =
1869            serde_json::from_value(published_keys("client-key-1")).expect("the key set reads");
1870        let confidential = ClientAuthentication {
1871            token_endpoint_auth_method: client_auth::PRIVATE_KEY_JWT.to_owned(),
1872            token_endpoint_auth_signing_alg: Some(client_auth::SIGNING_ALG.to_owned()),
1873            jwks: Some(keys),
1874            ..ClientAuthentication::public()
1875        };
1876        let with = |authentication: ClientAuthentication| ClientMetadataDocument {
1877            authentication,
1878            ..doc(client_id, vec!["https://client.example/cb"])
1879        };
1880        let jwks_uri = Some("https://client.example/jwks.json".to_owned());
1881        assert!(validate(client_id, with(confidential.clone())).is_ok());
1882        assert!(validate(
1883            client_id,
1884            with(ClientAuthentication {
1885                jwks: None,
1886                jwks_uri: jwks_uri.clone(),
1887                ..confidential.clone()
1888            })
1889        )
1890        .is_ok());
1891
1892        let id = client_id.to_owned();
1893        for (authentication, refused) in [
1894            (
1895                ClientAuthentication {
1896                    token_endpoint_auth_method: "client_secret_basic".to_owned(),
1897                    ..confidential.clone()
1898                },
1899                ClientMetadataError::UnsupportedAuthMethod(id.clone()),
1900            ),
1901            (
1902                ClientAuthentication {
1903                    token_endpoint_auth_signing_alg: None,
1904                    ..confidential.clone()
1905                },
1906                ClientMetadataError::UnsupportedSigningAlg(id.clone()),
1907            ),
1908            (
1909                ClientAuthentication {
1910                    token_endpoint_auth_signing_alg: Some("RS256".to_owned()),
1911                    ..confidential.clone()
1912                },
1913                ClientMetadataError::UnsupportedSigningAlg(id.clone()),
1914            ),
1915            (
1916                ClientAuthentication {
1917                    token_endpoint_auth_signing_alg: Some("none".to_owned()),
1918                    ..ClientAuthentication::public()
1919                },
1920                ClientMetadataError::UnsupportedSigningAlg(id.clone()),
1921            ),
1922            (
1923                ClientAuthentication {
1924                    jwks: None,
1925                    ..confidential.clone()
1926                },
1927                ClientMetadataError::NoClientKeys(id.clone()),
1928            ),
1929            (
1930                ClientAuthentication {
1931                    jwks: Some(Jwks::default()),
1932                    ..confidential.clone()
1933                },
1934                ClientMetadataError::NoClientKeys(id.clone()),
1935            ),
1936            (
1937                ClientAuthentication {
1938                    jwks_uri: jwks_uri.clone(),
1939                    ..confidential.clone()
1940                },
1941                ClientMetadataError::TwoKeySets(id.clone()),
1942            ),
1943            (
1944                ClientAuthentication {
1945                    jwks: None,
1946                    jwks_uri: Some("http://client.example/jwks.json".to_owned()),
1947                    ..confidential.clone()
1948                },
1949                ClientMetadataError::BadJwksUri("http://client.example/jwks.json".to_owned()),
1950            ),
1951        ] {
1952            assert_eq!(validate(client_id, with(authentication)), Err(refused));
1953        }
1954    }
1955
1956    /// A confidential client's `jwks_uri` is fetched when its document is,
1957    /// and cached with it, so the keys are read again whenever the document
1958    /// is. What `jwks_uri` served is not part of the document's content key.
1959    #[tokio::test]
1960    async fn a_jwks_uri_is_fetched_with_the_document_and_cached_with_it() {
1961        let listener = TcpListener::bind("127.0.0.1:0")
1962            .await
1963            .expect("loopback binds");
1964        let addr = listener.local_addr().expect("bound listener has an addr");
1965        let client_id = format!("{HOST}/id.json");
1966        let served = serde_json::json!({
1967            "client_id": client_id,
1968            "redirect_uris": ["https://app.example/cb"],
1969            "grant_types": ["authorization_code"],
1970            "response_types": ["code"],
1971            "token_endpoint_auth_method": client_auth::PRIVATE_KEY_JWT,
1972            "token_endpoint_auth_signing_alg": client_auth::SIGNING_ALG,
1973            "jwks_uri": format!("{HOST}/jwks.json"),
1974            "dpop_bound_access_tokens": true,
1975        })
1976        .to_string();
1977        let keys = published_keys("client-key-1").to_string();
1978        let app = Router::new()
1979            .route("/id.json", get(move || async move { json(served) }))
1980            .route("/jwks.json", get(move || async move { json(keys) }));
1981        tokio::spawn(async move {
1982            axum::serve(listener, app)
1983                .await
1984                .expect("client host serves");
1985        });
1986
1987        let cache = MemoryClientMetadataCache::new();
1988        let (document, key) = resolve_client(
1989            &reaching_only(addr),
1990            &cache,
1991            &client_id,
1992            LoopbackClients::Fetched,
1993        )
1994        .await
1995        .expect("a confidential client's document and keys resolve");
1996        assert!(document
1997            .authentication
1998            .keys()
1999            .is_some_and(|keys| !keys.is_empty()));
2000        let cached = cache.get(&client_id).expect("the document is cached");
2001        assert_eq!(
2002            cached.document.authentication.keys(),
2003            document.authentication.keys()
2004        );
2005
2006        let mut as_served = document.clone();
2007        as_served.authentication.jwks_uri_keys = None;
2008        assert_eq!(key, ClientKey::compute(&as_served));
2009    }
2010
2011    /// A `jwks_uri` is a URL the client chose, fetched on an unauthenticated
2012    /// request's behalf, so it is refused on this deployment's own network
2013    /// before anything is sent, as a `client_id` is. The host accepts and
2014    /// never answers, so a fetch would cost [`FETCH_TIMEOUT`].
2015    #[tokio::test(start_paused = true)]
2016    async fn a_jwks_uri_on_this_network_is_refused_before_the_fetch() {
2017        let addr = didbot_http::test_support::hung_server();
2018        let started = tokio::time::Instant::now();
2019        let err = fetch_jwks(
2020            &crate::oauth::client_metadata_http_client(),
2021            "https://client.example/id.json",
2022            &format!("https://{addr}/jwks.json"),
2023            LoopbackClients::Refused,
2024        )
2025        .await
2026        .expect_err("a real deployment does not fetch keys from its own network");
2027        assert!(
2028            matches!(err, ClientMetadataError::RefusedAddress(_)),
2029            "{err:?}"
2030        );
2031        assert_eq!(started.elapsed(), std::time::Duration::ZERO);
2032    }
2033}