Skip to main content

feather_reader/oauth/
discovery.rs

1//! Authorization-server discovery, and the validations that make it safe.
2//!
3//! The chain is: PDS → `/.well-known/oauth-protected-resource` → issuer →
4//! `/.well-known/oauth-authorization-server` → endpoints.
5//!
6//! Every link is attacker-influenced. The PDS comes from a DID document that a
7//! `did:web` host or `plc.directory` served; the issuer comes from the PDS. So
8//! each document must prove it is talking about itself:
9//!
10//! * the protected-resource document's `resource` must equal the PDS origin,
11//! * it must name exactly ONE authorization server,
12//! * the authorization-server document's `issuer` must equal the URL it was
13//!   fetched from — the mix-up defence, and the reason these documents must not
14//!   be fetched through a redirect,
15//! * and if the AS lists `protected_resources`, the PDS must appear in it.
16//!
17//! The last one closes the loop in the other direction: without it a PDS can
18//! unilaterally name an authorization server that has never heard of it.
19
20use anyhow::{bail, Context as _, Result};
21use serde_json::Value;
22
23/// The only DPoP signing algorithm this client has.
24const ES256: &str = "ES256";
25
26/// The endpoints a validated authorization server offers.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AuthorizationServer {
29    pub issuer: String,
30    pub par_endpoint: String,
31    pub authorization_endpoint: String,
32    pub token_endpoint: String,
33    /// RFC 7009 revocation. **Optional** — RFC 8414 does not require it, and a
34    /// server without one simply cannot be told about a sign-out. Absent rather
35    /// than an error, because refusing to log a user in over a missing LOGOUT
36    /// endpoint would be the wrong trade.
37    pub revocation_endpoint: Option<String>,
38}
39
40/// Resolve a PDS to its authorization server, both fetches and both validations.
41///
42/// Shared by the login path and the refresh path so the two cannot disagree
43/// about which server they are talking to. The order is not arbitrary: the
44/// protected-resource document names the issuer, and the issuer's own metadata
45/// is then required to agree — an authorization server that claims a different
46/// issuer than the one that pointed at it is the mix-up attack RFC 9207 exists
47/// for.
48///
49/// `expected_issuer` is a REQUIRED parameter, not an optional one, and that is
50/// the point.
51///
52/// The check it performs — that the re-discovered issuer is the one a grant
53/// already belongs to — was a free function every caller had to remember. It was
54/// applied to the callback and the refresh paths and forgotten on revocation,
55/// where the body carries the REFRESH TOKEN. A caller with no prior issuer must
56/// now say so explicitly with `None`, which cannot be done by accident.
57pub async fn discover(
58    http: &reqwest::Client,
59    pds_url: &str,
60    auth_method: &str,
61    expected_issuer: Option<&str>,
62) -> Result<AuthorizationServer> {
63    discover_with(
64        |url| async move {
65            super::fetch::get_json(http, &url, super::fetch::JSON)
66                .await
67                .with_context(|| format!("fetching {url}"))
68        },
69        pds_url,
70        auth_method,
71        expected_issuer,
72    )
73    .await
74}
75
76/// [`discover`] with the fetch injected.
77///
78/// The orchestration — which URL each document is fetched from, and which value
79/// is carried forward as the expected issuer — is where the mix-up defence
80/// actually lives, and it was untested because testing it appeared to need a
81/// network: the SSRF guard rejects loopback, so there is no mock server to point
82/// at. Taking the fetch as an argument removes that obstacle without putting a
83/// test-only bypass inside the guard, which would weaken the very control these
84/// tests exist to protect.
85///
86/// `fetch` is called with the FULL url, so a test can assert on which locations
87/// were asked for — the thing a fixture handed two documents cannot see.
88pub async fn discover_with<F, Fut>(
89    fetch: F,
90    pds_url: &str,
91    auth_method: &str,
92    expected_issuer: Option<&str>,
93) -> Result<AuthorizationServer>
94where
95    F: Fn(String) -> Fut,
96    Fut: std::future::Future<Output = Result<Value>>,
97{
98    let prm_url = protected_resource_url(pds_url)?;
99    let prm = fetch(prm_url).await?;
100
101    // The issuer named by the PDS decides where the second document comes from.
102    let issuer = validate_protected_resource(&prm, pds_url)?;
103    let asm_url = authorization_server_url(&issuer);
104    let asm = fetch(asm_url.clone()).await?;
105
106    let server = resolve_documents(&prm, &asm, &asm_url, pds_url, auth_method)?;
107
108    // Enforced HERE rather than in `discover` so it is reachable with an
109    // injected fetch. Putting it in `discover` would have made the check
110    // untestable for exactly the reason this module was split up in the first
111    // place: `discover` needs a network, and the SSRF guard rejects loopback.
112    if let Some(expected) = expected_issuer {
113        super::session::same_issuer(&server.issuer, expected)?;
114    }
115    Ok(server)
116}
117
118/// Where a PDS's protected-resource document lives.
119pub fn protected_resource_url(pds_url: &str) -> Result<String> {
120    Ok(format!(
121        "{}/.well-known/oauth-protected-resource",
122        origin_of(pds_url)?
123    ))
124}
125
126/// Where an issuer's authorization-server metadata lives.
127///
128/// Derived from the ISSUER, never from the PDS. The mix-up defence is
129/// `metadata.issuer == the URL we fetched from`; fetch from anywhere else and
130/// that comparison is checking a document against a location it did not come
131/// from.
132pub fn authorization_server_url(issuer: &str) -> String {
133    format!("{issuer}/.well-known/oauth-authorization-server")
134}
135
136/// Validate an already-fetched pair of discovery documents.
137///
138/// The decision half of [`discover`], split out because the WIRING is where the
139/// mix-up defence actually lives and it had no coverage: two mutations passed
140/// the whole suite — one feeding the authorization server's own `issuer` claim
141/// in as the expected value (so the check became `declared == declared`), and
142/// one fetching the second document from the PDS instead of the issuer.
143///
144/// `asm_fetched_from` is required rather than assumed: the comparison only means
145/// anything if the document really came from the issuer's own well-known
146/// location, so that precondition is checked here instead of trusted.
147pub fn resolve_documents(
148    prm: &Value,
149    asm: &Value,
150    asm_fetched_from: &str,
151    pds_url: &str,
152    auth_method: &str,
153) -> Result<AuthorizationServer> {
154    let issuer = validate_protected_resource(prm, pds_url)?;
155
156    let expected = authorization_server_url(&issuer);
157    if asm_fetched_from != expected {
158        bail!(
159            "the authorization-server metadata was fetched from {asm_fetched_from:?}, not from \
160             the issuer's own {expected:?}; the issuer comparison would be meaningless"
161        );
162    }
163
164    // The expected issuer comes from the PROTECTED-RESOURCE document — i.e. the
165    // URL this metadata was fetched from — never from the metadata's own claim.
166    validate_authorization_server(asm, &issuer, pds_url, auth_method)
167}
168
169/// The origin of a URL: scheme + host + non-default port, and nothing else.
170///
171/// Built from the parsed components rather than sliced out of the input, so
172/// userinfo cannot survive into it — `https://u:p@host/x` has origin
173/// `https://host`, and a slice would have kept the credentials. Both sides of
174/// the RFC 9728 `resource` comparison are attacker-influenced, so this needs to
175/// be an origin in fact and not just in name.
176pub fn origin_of(url: &str) -> Result<String> {
177    let parsed = url::Url::parse(url).with_context(|| format!("{url:?} is not a URL"))?;
178    let host = parsed
179        .host_str()
180        .with_context(|| format!("{url:?} has no host"))?;
181    Ok(match parsed.port() {
182        // `Url::port` is None for the scheme's default, so 443 drops out here.
183        Some(port) => format!("{}://{host}:{port}", parsed.scheme()),
184        None => format!("{}://{host}", parsed.scheme()),
185    })
186}
187
188/// An issuer identifier must be canonical before it can be compared.
189///
190/// The mix-up defence rests on `metadata.issuer == the URL we fetched from`
191/// being an exact string comparison. If either side could carry a path, a
192/// trailing slash or userinfo, that comparison would be ambiguous — and an
193/// ambiguous security check is one that eventually passes when it should not.
194pub fn validate_issuer_form(issuer: &str) -> Result<()> {
195    let parsed = url::Url::parse(issuer)
196        .with_context(|| format!("issuer {issuer:?} is not an absolute URL"))?;
197    if parsed.scheme() != "https" {
198        bail!("issuer {issuer:?} must be https");
199    }
200    if !parsed.has_host() {
201        bail!("issuer {issuer:?} has no host");
202    }
203    if parsed.path() != "" && parsed.path() != "/" {
204        bail!("issuer {issuer:?} must have no path");
205    }
206    if issuer.ends_with('/') {
207        bail!("issuer {issuer:?} must not have a trailing slash");
208    }
209    if parsed.query().is_some() {
210        bail!("issuer {issuer:?} must have no query string");
211    }
212    if parsed.fragment().is_some() {
213        bail!("issuer {issuer:?} must have no fragment");
214    }
215    if !parsed.username().is_empty() || parsed.password().is_some() {
216        bail!("issuer {issuer:?} must not carry credentials");
217    }
218    // The catch-all, and the one that actually makes the comparison sound: the
219    // issuer must be spelled EXACTLY as its own origin. That covers a default
220    // port (the spec forbids `:443`), host casing, and percent-escapes in one
221    // check, rather than enumerating normalizations and missing some.
222    let canonical = origin_of(issuer)?;
223    if issuer != canonical {
224        bail!("issuer {issuer:?} is not in canonical form (expected {canonical:?})");
225    }
226    Ok(())
227}
228
229/// Validate `/.well-known/oauth-protected-resource` and return its issuer.
230///
231/// Two checks, both required:
232///
233/// * `resource` must equal the PDS **origin** (RFC 9728 §3.3). Without it, a
234///   document served by one host can claim to describe another.
235/// * `authorization_servers` must hold **exactly one** entry. atproto requires
236///   one and only one; taking `[0]` would silently accept a hostile document
237///   advertising several and pick whichever the PDS listed first, turning a
238///   document that should fail closed into attacker-chosen AS selection.
239pub fn validate_protected_resource(metadata: &Value, pds_url: &str) -> Result<String> {
240    let expected = origin_of(pds_url)?;
241    let resource = metadata
242        .get("resource")
243        .and_then(Value::as_str)
244        .context("protected-resource metadata has no `resource`")?;
245    if resource != expected {
246        bail!(
247            "protected-resource `resource` is {resource:?}, expected the PDS origin {expected:?}"
248        );
249    }
250
251    let servers = metadata
252        .get("authorization_servers")
253        .and_then(Value::as_array)
254        .context("protected-resource metadata has no `authorization_servers`")?;
255    if servers.len() != 1 {
256        bail!(
257            "atproto requires exactly one authorization server, found {}",
258            servers.len()
259        );
260    }
261    let issuer = servers[0]
262        .as_str()
263        .context("`authorization_servers` entry is not a string")?;
264    validate_issuer_form(issuer)?;
265    Ok(issuer.to_string())
266}
267
268/// Require an array-valued metadata field to contain `wanted`.
269fn require_listed(metadata: &Value, field: &str, wanted: &str) -> Result<()> {
270    let values = metadata
271        .get(field)
272        .and_then(Value::as_array)
273        .with_context(|| format!("authorization-server metadata has no `{field}`"))?;
274    if !values.iter().filter_map(Value::as_str).any(|v| v == wanted) {
275        bail!("authorization-server `{field}` does not include {wanted:?}");
276    }
277    Ok(())
278}
279
280/// Require an array-valued metadata field NOT to contain `forbidden`.
281fn require_absent(metadata: &Value, field: &str, forbidden: &str) -> Result<()> {
282    if let Some(values) = metadata.get(field).and_then(Value::as_array) {
283        if values
284            .iter()
285            .filter_map(Value::as_str)
286            .any(|v| v == forbidden)
287        {
288            bail!("authorization-server `{field}` includes {forbidden:?}, which is not allowed");
289        }
290    }
291    Ok(())
292}
293
294/// Read a required endpoint, requiring an absolute https URL.
295fn require_endpoint(metadata: &Value, field: &str, issuer: &str) -> Result<String> {
296    let raw = metadata
297        .get(field)
298        .and_then(Value::as_str)
299        .with_context(|| format!("authorization-server metadata has no `{field}`"))?;
300    let parsed = url::Url::parse(raw)
301        .with_context(|| format!("`{field}` {raw:?} is not an absolute URL"))?;
302    if parsed.scheme() != "https" {
303        bail!("`{field}` must be https, got {raw:?}");
304    }
305    // **Endpoints must live on the issuer's own origin.**
306    //
307    // RFC 8414 does not require co-location, so this is a hardening choice
308    // rather than a conformance check — and it is made deliberately, because
309    // `authorization_endpoint` is a URL this app 303s a browser to from its own
310    // `/login`. Without it, anyone who can start a login with a `did:web` they
311    // control turns `/login` into an arbitrary-https-redirect on our origin: the
312    // chain is self-consistent, every other discovery check passes, and the
313    // endpoint points wherever they like.
314    //
315    // The atproto profile co-locates these in practice — the frozen real-PDS
316    // fixture in the tests below is the evidence, as is the entryway/PDS split
317    // where the PDS is `*.host.bsky.network` and the issuer and its endpoints
318    // are all `bsky.social` — so the cost is refusing a server that is unusual
319    // rather than one that is wrong.
320    //
321    // Note what this does NOT do: an attacker who controls the `did:web` chain
322    // controls the issuer origin too, so `/login` can still redirect to any
323    // origin they can serve two self-consistent documents from. This narrows
324    // the target to origins that look like authorization servers; it does not
325    // remove the redirect.
326    let origin = origin_of(raw)?;
327    if origin != issuer {
328        bail!(
329            "`{field}` {raw:?} is on {origin:?}, not the issuer's own origin {issuer:?}; \
330             refusing to treat it as part of this authorization server"
331        );
332    }
333    Ok(raw.to_string())
334}
335
336/// The same validation for a field that may legitimately be absent.
337///
338/// Present-but-unusable is still an ERROR: a `revocation_endpoint` of
339/// `http://…` or a relative path is a broken document, and silently treating it
340/// as absent would turn a misconfigured server into a silent no-op sign-out.
341fn optional_endpoint(metadata: &Value, field: &str, issuer: &str) -> Result<Option<String>> {
342    match metadata.get(field) {
343        None | Some(Value::Null) => Ok(None),
344        Some(_) => require_endpoint(metadata, field, issuer).map(Some),
345    }
346}
347
348/// Validate `/.well-known/oauth-authorization-server` and return its endpoints.
349///
350/// `issuer` is the URL the document was fetched from; `pds_url` the PDS that
351/// named it; `auth_method` the client-authentication method we intend to use
352/// (`none` for the localhost dev client, `private_key_jwt` in production).
353pub fn validate_authorization_server(
354    metadata: &Value,
355    issuer: &str,
356    pds_url: &str,
357    auth_method: &str,
358) -> Result<AuthorizationServer> {
359    // The mix-up defence. RFC 8414 §3.3: the document must claim the identity it
360    // was served under, or a hostile PDS can name an authorization server whose
361    // metadata declares somebody else's issuer.
362    let declared = metadata
363        .get("issuer")
364        .and_then(Value::as_str)
365        .context("authorization-server metadata has no `issuer`")?;
366    if declared != issuer {
367        bail!("authorization-server declares issuer {declared:?} but was fetched from {issuer:?}");
368    }
369
370    // And the other direction: a PDS cannot unilaterally name an authorization
371    // server that has never heard of it. Only checked when the AS declares the
372    // list — absent means "not stated", not "not protected".
373    if let Some(resources) = metadata
374        .get("protected_resources")
375        .and_then(Value::as_array)
376    {
377        let pds_origin = origin_of(pds_url)?;
378        if !resources
379            .iter()
380            .filter_map(Value::as_str)
381            .any(|r| r == pds_origin)
382        {
383            bail!("PDS {pds_origin:?} is not listed in the authorization server's protected_resources");
384        }
385    }
386
387    // Capability preflight: fail here, with a legible reason, rather than
388    // several requests later with a protocol error.
389    if metadata
390        .get("client_id_metadata_document_supported")
391        .and_then(Value::as_bool)
392        != Some(true)
393    {
394        bail!("authorization server does not support client-id metadata documents");
395    }
396    // atproto mandates PAR, and mandates that the AS advertises it.
397    if metadata
398        .get("require_pushed_authorization_requests")
399        .and_then(Value::as_bool)
400        != Some(true)
401    {
402        bail!("authorization server does not require pushed authorization requests");
403    }
404    // Mandated `true` by atproto, and load-bearing downstream: the callback
405    // treats a MISSING `iss` as a rejection (RFC 9207), which is only sound
406    // because a conformant server always sends one. Checking it here turns a
407    // post-approval failure into a preflight one.
408    if metadata
409        .get("authorization_response_iss_parameter_supported")
410        .and_then(Value::as_bool)
411        != Some(true)
412    {
413        bail!("authorization server does not send the `iss` response parameter");
414    }
415
416    require_listed(metadata, "code_challenge_methods_supported", "S256")?;
417    // `plain` is not merely "not required" -- the spec says it "is not allowed".
418    require_absent(metadata, "code_challenge_methods_supported", "plain")?;
419    require_listed(metadata, "dpop_signing_alg_values_supported", ES256)?;
420    require_listed(metadata, "response_types_supported", "code")?;
421    require_listed(metadata, "grant_types_supported", "authorization_code")?;
422    require_listed(metadata, "grant_types_supported", "refresh_token")?;
423    require_listed(
424        metadata,
425        "token_endpoint_auth_methods_supported",
426        auth_method,
427    )?;
428    require_listed(metadata, "scopes_supported", "atproto")?;
429
430    // An ABSENT signing-alg list means ES256, not "unknown". The spec says
431    // clients and servers "currently must support the ES256 cryptographic
432    // system", so bailing here would reject conformant servers.
433    if metadata
434        .get("token_endpoint_auth_signing_alg_values_supported")
435        .is_some()
436    {
437        require_listed(
438            metadata,
439            "token_endpoint_auth_signing_alg_values_supported",
440            ES256,
441        )?;
442        // The spec forbids `none` here outright.
443        require_absent(
444            metadata,
445            "token_endpoint_auth_signing_alg_values_supported",
446            "none",
447        )?;
448    }
449
450    Ok(AuthorizationServer {
451        issuer: issuer.to_string(),
452        par_endpoint: require_endpoint(metadata, "pushed_authorization_request_endpoint", issuer)?,
453        authorization_endpoint: require_endpoint(metadata, "authorization_endpoint", issuer)?,
454        token_endpoint: require_endpoint(metadata, "token_endpoint", issuer)?,
455        revocation_endpoint: optional_endpoint(metadata, "revocation_endpoint", issuer)?,
456    })
457}
458
459#[cfg(test)]
460mod live_pds {
461    //! **Live discovery against a real atproto PDS.**
462    //!
463    //! `#[ignore]` — it talks to the network. Run with:
464    //!
465    //! ```text
466    //! cargo test --lib -- --ignored --nocapture live_pds
467    //! ```
468    //!
469    //! Closes part of "never validated against a live PDS": handle resolution,
470    //! the protected-resource document, AS metadata, and BOTH mix-up defences
471    //! (issuer self-description and endpoint co-location) run against a server
472    //! that pushes back. Read-only and unauthenticated — no account, no consent,
473    //! no tokens, no side effects on the PDS.
474    //!
475    //! What it does NOT cover: PAR, `private_key_jwt`, the code exchange and
476    //! revocation. Those need our client metadata to be fetchable by the PDS at
477    //! the `client_id` URL, which means the cutover itself.
478    use super::*;
479
480    const SUBJECT: &str = "justin-stanley.com";
481    const PLC: &str = "https://plc.directory";
482
483    #[tokio::test]
484    #[ignore]
485    async fn discovery_round_trips_against_a_live_pds() -> anyhow::Result<()> {
486        let http = crate::feed::build_client()?;
487        let resolver = crate::oauth::resolve::resolver()?;
488
489        let account = crate::oauth::resolve::resolve(&resolver, &http, SUBJECT, PLC).await?;
490        println!("resolved {SUBJECT}");
491        println!("  did: {}", account.did);
492        println!("  handle verified: {:?}", account.handle);
493        println!("  pds: {}", account.pds_url);
494
495        let server = discover(&http, &account.pds_url, "private_key_jwt", None).await?;
496        println!("discovery:");
497        println!("  issuer:        {}", server.issuer);
498        println!("  par:           {}", server.par_endpoint);
499        println!("  authorization: {}", server.authorization_endpoint);
500        println!("  token:         {}", server.token_endpoint);
501        println!(
502            "  revocation:    {}",
503            server.revocation_endpoint.as_deref().unwrap_or("(absent)")
504        );
505
506        // A real server must satisfy the mix-up defences, not just parse.
507        assert!(
508            server.issuer.starts_with("https://"),
509            "issuer must be https"
510        );
511        assert!(
512            !server.par_endpoint.is_empty() && !server.token_endpoint.is_empty(),
513            "a PDS must advertise PAR and token endpoints"
514        );
515        // Re-running with the issuer we just learned must be accepted — this is
516        // the check the refresh and revoke paths make on every call.
517        let again = discover(
518            &http,
519            &account.pds_url,
520            "private_key_jwt",
521            Some(&server.issuer),
522        )
523        .await?;
524        assert_eq!(again.issuer, server.issuer);
525
526        // And an impostor issuer must be REFUSED. This is the mix-up defence
527        // running against real metadata rather than a fixture.
528        let bad = discover(
529            &http,
530            &account.pds_url,
531            "private_key_jwt",
532            Some("https://evil.example"),
533        )
534        .await;
535        // **Assert WHY it was refused, not merely that it was.** `is_err()` alone
536        // is satisfied by a transient network failure on this third call, so it
537        // would keep passing if the mix-up check stopped firing and something
538        // else errored instead — the defence would silently stop being tested
539        // while the test stayed green.
540        let err = format!(
541            "{:#}",
542            bad.expect_err("a mismatched issuer must be refused")
543        );
544        assert!(
545            err.contains("different authorization server")
546                && err.contains("refusing to send credentials"),
547            "refused, but not BY the mix-up defence — this test would not notice \
548             if that check stopped firing: {err}"
549        );
550        println!("  mix-up defence: refused a mismatched issuer, by `same_issuer`");
551
552        // Revocation support is what decides whether logout can tell the PDS.
553        match server.revocation_endpoint {
554            Some(ref r) => println!("\n  => this PDS SUPPORTS RFC 7009 revocation at {r}"),
555            None => println!("\n  => this PDS advertises NO revocation endpoint"),
556        }
557        Ok(())
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use serde_json::json;
565
566    /// **An endpoint on a foreign origin is refused.**
567    ///
568    /// `authorization_endpoint` is a URL the app 303s a browser to from its own
569    /// `/login`. Left unconstrained, anyone who can start a login with a
570    /// `did:web` they control turns `/login` into an arbitrary-https-redirect on
571    /// our origin — every other discovery check passes, because the hostile
572    /// documents are self-consistent.
573    ///
574    /// RFC 8414 permits co-location to be absent, so this refuses a server that
575    /// is unusual rather than one that is wrong. The real-PDS fixture below is
576    /// the evidence that atproto co-locates in practice.
577    #[test]
578    fn an_endpoint_on_a_foreign_origin_is_refused() {
579        for field in [
580            "pushed_authorization_request_endpoint",
581            "authorization_endpoint",
582            "token_endpoint",
583            "revocation_endpoint",
584        ] {
585            let mut asm = as_metadata();
586            asm[field] = json!("https://totally-other.example/authorize");
587            let err = match validate_authorization_server(&asm, ISS, PDS, "none") {
588                Err(err) => err,
589                Ok(_) => panic!("accepted a foreign-origin {field}"),
590            };
591            let rendered = format!("{err:#}");
592            assert!(
593                rendered.contains("issuer's own origin"),
594                "{field} failed for the wrong reason: {rendered}"
595            );
596        }
597    }
598
599    /// An ABSENT `revocation_endpoint` is absent, not an error. RFC 8414 does
600    /// not require one, and refusing to log a user in because a server offers no
601    /// way to log them out later would be the wrong trade.
602    #[test]
603    fn an_absent_revocation_endpoint_is_tolerated() {
604        assert_eq!(
605            optional_endpoint(
606                &json!({}),
607                "revocation_endpoint",
608                "https://auth.example.com"
609            )
610            .unwrap(),
611            None
612        );
613        assert_eq!(
614            optional_endpoint(
615                &json!({ "revocation_endpoint": null }),
616                "revocation_endpoint",
617                "https://auth.example.com"
618            )
619            .unwrap(),
620            None
621        );
622    }
623
624    /// **Present but unusable is an ERROR, not "absent".**
625    ///
626    /// Folding a broken value into `None` would turn a misconfigured server into
627    /// a silent no-op sign-out: revocation would be skipped, the local row would
628    /// still be deleted, and the logout would look entirely successful while the
629    /// refresh token stayed live at the PDS.
630    #[test]
631    fn a_malformed_revocation_endpoint_is_an_error_rather_than_absent() {
632        let plain_http = json!({ "revocation_endpoint": "http://auth.example.com/revoke" });
633        let err = optional_endpoint(
634            &plain_http,
635            "revocation_endpoint",
636            "https://auth.example.com",
637        )
638        .expect_err("plain http must be refused");
639        assert!(format!("{err:#}").contains("must be https"));
640
641        let relative = json!({ "revocation_endpoint": "/revoke" });
642        assert!(
643            optional_endpoint(&relative, "revocation_endpoint", "https://auth.example.com")
644                .is_err()
645        );
646
647        let wrong_type = json!({ "revocation_endpoint": 42 });
648        assert!(optional_endpoint(
649            &wrong_type,
650            "revocation_endpoint",
651            "https://auth.example.com"
652        )
653        .is_err());
654    }
655
656    const PDS: &str = "https://pds.example.com";
657    const ISS: &str = "https://auth.example.com";
658
659    fn protected_resource() -> serde_json::Value {
660        json!({ "resource": PDS, "authorization_servers": [ISS] })
661    }
662
663    fn as_metadata() -> serde_json::Value {
664        json!({
665            "issuer": ISS,
666            "pushed_authorization_request_endpoint": format!("{ISS}/par"),
667            "authorization_endpoint": format!("{ISS}/authorize"),
668            "token_endpoint": format!("{ISS}/token"),
669            "client_id_metadata_document_supported": true,
670            "require_pushed_authorization_requests": true,
671            "code_challenge_methods_supported": ["S256"],
672            "dpop_signing_alg_values_supported": ["ES256"],
673            "token_endpoint_auth_methods_supported": ["none", "private_key_jwt"],
674            "token_endpoint_auth_signing_alg_values_supported": ["ES256"],
675            "response_types_supported": ["code"],
676            "grant_types_supported": ["authorization_code", "refresh_token"],
677            "scopes_supported": ["atproto", "transition:generic"],
678            "authorization_response_iss_parameter_supported": true
679        })
680    }
681
682    // ── issuer form ──────────────────────────────────────────────────────────
683
684    #[test]
685    fn a_well_formed_issuer_is_accepted() {
686        assert!(validate_issuer_form("https://auth.example.com").is_ok());
687        assert!(validate_issuer_form("https://auth.example.com:8443").is_ok());
688    }
689
690    /// A non-canonical issuer would make the `issuer == fetched-from` comparison
691    /// ambiguous, which is the comparison the mix-up defence rests on.
692    #[test]
693    fn a_non_canonical_issuer_is_rejected() {
694        for issuer in [
695            "http://auth.example.com",       // not https
696            "https://auth.example.com/",     // trailing slash
697            "https://auth.example.com/path", // path
698            "https://auth.example.com?a=1",  // query
699            "https://auth.example.com#f",    // fragment
700            "https://u:p@auth.example.com",  // userinfo
701            "auth.example.com",              // not absolute
702            "",
703            // Spec: "a default port (443 for HTTPS) must not be included".
704            "https://auth.example.com:443",
705            // Case and percent-escapes get normalized by any URL parser, so an
706            // un-normalized spelling would compare unequal to itself.
707            "https://AUTH.example.com",
708            "https://auth%2eexample.com",
709        ] {
710            assert!(validate_issuer_form(issuer).is_err(), "accepted {issuer:?}");
711        }
712    }
713
714    /// An origin is scheme + host + port. Userinfo is not part of it, and
715    /// leaving it in would make the RFC 9728 `resource` comparison compare
716    /// something that is not an origin.
717    #[test]
718    fn origin_of_drops_userinfo_path_query_and_default_ports() {
719        assert_eq!(origin_of("https://u:p@pds.example.com/x").unwrap(), PDS);
720        assert_eq!(origin_of("https://pds.example.com/a/b?c=1#d").unwrap(), PDS);
721        assert_eq!(origin_of("https://pds.example.com:443").unwrap(), PDS);
722        assert_eq!(
723            origin_of("https://pds.example.com:8443").unwrap(),
724            "https://pds.example.com:8443"
725        );
726    }
727
728    // ── protected-resource metadata ──────────────────────────────────────────
729
730    #[test]
731    fn a_valid_protected_resource_document_yields_its_issuer() {
732        assert_eq!(
733            validate_protected_resource(&protected_resource(), PDS).unwrap(),
734            ISS
735        );
736    }
737
738    /// RFC 9728 §3.3. Without it, a document served by one host can claim to
739    /// describe another.
740    #[test]
741    fn the_resource_must_equal_the_pds_origin() {
742        let mut doc = protected_resource();
743        doc["resource"] = json!("https://other.example.com");
744        assert!(validate_protected_resource(&doc, PDS).is_err());
745    }
746
747    /// The comparison is against the ORIGIN, so a path on the PDS URL is
748    /// discarded rather than causing a spurious mismatch.
749    #[test]
750    fn the_resource_comparison_uses_the_origin_of_the_pds_url() {
751        assert!(
752            validate_protected_resource(&protected_resource(), "https://pds.example.com/xrpc")
753                .is_ok()
754        );
755        assert!(
756            validate_protected_resource(&protected_resource(), "https://pds.example.com/").is_ok()
757        );
758    }
759
760    /// **atproto requires exactly one.** Taking `[0]` would silently accept a
761    /// malformed or hostile document advertising several and pick whichever the
762    /// PDS listed first — turning a document that should fail closed into
763    /// attacker-chosen AS selection.
764    #[test]
765    fn exactly_one_authorization_server_is_required() {
766        let mut doc = protected_resource();
767        doc["authorization_servers"] = json!([ISS, "https://evil.example.com"]);
768        assert!(validate_protected_resource(&doc, PDS).is_err());
769
770        doc["authorization_servers"] = json!([]);
771        assert!(validate_protected_resource(&doc, PDS).is_err());
772
773        doc.as_object_mut().unwrap().remove("authorization_servers");
774        assert!(validate_protected_resource(&doc, PDS).is_err());
775    }
776
777    #[test]
778    fn the_named_issuer_must_itself_be_well_formed() {
779        let mut doc = protected_resource();
780        doc["authorization_servers"] = json!(["https://auth.example.com/path"]);
781        assert!(validate_protected_resource(&doc, PDS).is_err());
782    }
783
784    // ── authorization-server metadata ────────────────────────────────────────
785
786    #[test]
787    fn a_valid_as_document_yields_its_endpoints() {
788        let server =
789            validate_authorization_server(&as_metadata(), ISS, PDS, "private_key_jwt").unwrap();
790        assert_eq!(server.issuer, ISS);
791        assert_eq!(server.par_endpoint, format!("{ISS}/par"));
792        assert_eq!(server.authorization_endpoint, format!("{ISS}/authorize"));
793        assert_eq!(server.token_endpoint, format!("{ISS}/token"));
794    }
795
796    /// **The mix-up defence.** RFC 8414 §3.3; the reference comments this
797    /// "Validate the issuer (MIX-UP attacks)". A hostile PDS names an AS whose
798    /// metadata declares someone else's issuer; without this check we would hold
799    /// a token set labelled with an issuer that never minted it.
800    #[test]
801    fn the_documents_issuer_must_equal_the_url_it_came_from() {
802        let mut doc = as_metadata();
803        doc["issuer"] = json!("https://someone-else.example.com");
804        assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err());
805    }
806
807    /// Closes the loop the other way: a PDS cannot unilaterally name an AS that
808    /// has never heard of it.
809    #[test]
810    fn a_declared_protected_resources_list_must_contain_the_pds() {
811        let mut doc = as_metadata();
812        doc["protected_resources"] = json!(["https://other.example.com"]);
813        assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err());
814
815        doc["protected_resources"] = json!([PDS]);
816        assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_ok());
817    }
818
819    /// Absent is fine — the check only applies when the AS declares the list.
820    #[test]
821    fn an_absent_protected_resources_list_is_not_an_error() {
822        let doc = as_metadata();
823        assert!(doc.get("protected_resources").is_none());
824        assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_ok());
825    }
826
827    // ── capability preflight ─────────────────────────────────────────────────
828
829    #[test]
830    fn required_capabilities_are_enforced() {
831        let cases: &[(&str, serde_json::Value)] = &[
832            ("client_id_metadata_document_supported", json!(false)),
833            ("code_challenge_methods_supported", json!(["plain"])),
834            ("dpop_signing_alg_values_supported", json!(["ES384"])),
835            ("response_types_supported", json!(["token"])),
836            ("grant_types_supported", json!(["authorization_code"])),
837            (
838                "token_endpoint_auth_methods_supported",
839                json!(["client_secret_basic"]),
840            ),
841            (
842                "token_endpoint_auth_signing_alg_values_supported",
843                json!(["RS256"]),
844            ),
845            // --- the four the fixture carried but nothing asserted ---
846            ("scopes_supported", json!(["transition:generic"])),
847            ("require_pushed_authorization_requests", json!(false)),
848            (
849                "authorization_response_iss_parameter_supported",
850                json!(false),
851            ),
852            // `plain` is forbidden outright, not merely "S256 must also be there".
853            ("code_challenge_methods_supported", json!(["S256", "plain"])),
854            // The spec forbids `none` in the signing-alg list.
855            (
856                "token_endpoint_auth_signing_alg_values_supported",
857                json!(["ES256", "none"]),
858            ),
859        ];
860        for (field, bad) in cases {
861            let mut doc = as_metadata();
862            doc[*field] = bad.clone();
863            assert!(
864                validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err(),
865                "accepted {field} = {bad}"
866            );
867        }
868    }
869
870    /// **Every field the fixture carries must be load-bearing.** Four spec-`must`
871    /// checks were missing precisely because the fixture supplied them and no
872    /// test ever varied them -- the fixture manufactured the appearance of
873    /// coverage. Removing any field must now break something, so a check that
874    /// silently disappears shows up here.
875    #[test]
876    fn every_field_in_the_fixture_is_load_bearing() {
877        // The single deliberate exception: an absent signing-alg list means
878        // ES256 rather than "unknown", so removing it MUST still validate. It is
879        // named here rather than skipped silently, so the exemption is a
880        // decision on the record instead of a gap.
881        const OPTIONAL: &str = "token_endpoint_auth_signing_alg_values_supported";
882
883        let base = as_metadata();
884        for field in base.as_object().unwrap().keys() {
885            let mut doc = base.clone();
886            doc.as_object_mut().unwrap().remove(field);
887            let result = validate_authorization_server(&doc, ISS, PDS, "private_key_jwt");
888            if field == OPTIONAL {
889                assert!(result.is_ok(), "`{field}` is documented as optional");
890            } else {
891                assert!(
892                    result.is_err(),
893                    "removing `{field}` changed nothing -- it is unchecked, or it \
894                     does not belong in the fixture"
895                );
896            }
897        }
898    }
899
900    #[test]
901    fn a_missing_required_endpoint_is_rejected() {
902        for field in [
903            "pushed_authorization_request_endpoint",
904            "authorization_endpoint",
905            "token_endpoint",
906        ] {
907            let mut doc = as_metadata();
908            doc.as_object_mut().unwrap().remove(field);
909            assert!(
910                validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err(),
911                "accepted a document with no {field}"
912            );
913        }
914    }
915
916    /// Spec: clients and servers "currently must support the ES256
917    /// cryptographic system", so an absent list means ES256 rather than
918    /// "unknown" — bailing here would reject conformant servers.
919    #[test]
920    fn an_absent_signing_alg_list_defaults_to_es256_rather_than_failing() {
921        let mut doc = as_metadata();
922        doc.as_object_mut()
923            .unwrap()
924            .remove("token_endpoint_auth_signing_alg_values_supported");
925        assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_ok());
926    }
927
928    /// The negotiated method must be checked against what the AS accepts —
929    /// the dev client authenticates with `none`, production with
930    /// `private_key_jwt`, and an AS supporting only one of them must fail
931    /// loudly rather than at PAR.
932    #[test]
933    fn the_negotiated_auth_method_must_be_supported() {
934        let mut doc = as_metadata();
935        doc["token_endpoint_auth_methods_supported"] = json!(["private_key_jwt"]);
936        assert!(validate_authorization_server(&doc, ISS, PDS, "none").is_err());
937        assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_ok());
938
939        doc["token_endpoint_auth_methods_supported"] = json!(["none"]);
940        assert!(validate_authorization_server(&doc, ISS, PDS, "none").is_ok());
941        assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err());
942    }
943
944    /// Endpoints must be absolute https URLs — a relative or http endpoint
945    /// would be a redirect target we hand the browser, or a credential-bearing
946    /// POST in the clear.
947    #[test]
948    fn endpoints_must_be_absolute_https_urls() {
949        for field in [
950            "pushed_authorization_request_endpoint",
951            "authorization_endpoint",
952            "token_endpoint",
953        ] {
954            for bad in ["/par", "http://auth.example.com/par", "not a url"] {
955                let mut doc = as_metadata();
956                doc[field] = json!(bad);
957                let err = validate_authorization_server(&doc, ISS, PDS, "private_key_jwt")
958                    .expect_err(&format!("accepted {field} = {bad}"));
959                // **By message.** All three used to be `is_err()` only, and all
960                // three are refused by OTHER checks — the malformed ones by
961                // `Url::parse`, the `http://` one by the issuer-origin check
962                // (`http://` and `https://` are different origins). With the
963                // scheme check deleted this test stayed green. An AS
964                // publishing `http://` endpoints on its own origin — credentials
965                // POSTed in the clear — must be refused for THAT reason.
966                if bad.starts_with("http://") {
967                    assert!(
968                        format!("{err:#}").contains("must be https"),
969                        "{field} = {bad} was refused for the wrong reason: {err:#}"
970                    );
971                }
972            }
973        }
974    }
975    /// **Against a REAL PDS.** These are the documents `pds.justin-stanley.com`
976    /// actually served on 2026-09-12, captured verbatim.
977    ///
978    /// The preflight in this module is deliberately strict, and the failure mode
979    /// of strictness is rejecting a conformant server — which unit tests built
980    /// from a hand-written fixture cannot detect, because the fixture is written
981    /// to pass. This one was written by somebody else's implementation.
982    ///
983    /// It also carries fields this module does not look at
984    /// (`request_object_signing_alg_values_supported`, `ui_locales_supported`,
985    /// and others), confirming that unknown members are ignored rather than
986    /// tripping anything.
987    #[test]
988    fn a_real_pds_passes_the_preflight() {
989        const REAL_PROTECTED_RESOURCE: &str = r#"{"resource":"https://pds.justin-stanley.com","authorization_servers":["https://pds.justin-stanley.com"],"scopes_supported":[],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}"#;
990        const REAL_AUTHORIZATION_SERVER: &str = r#"{"issuer":"https://pds.justin-stanley.com","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://pds.justin-stanley.com/oauth/jwks","authorization_endpoint":"https://pds.justin-stanley.com/oauth/authorize","token_endpoint":"https://pds.justin-stanley.com/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://pds.justin-stanley.com/oauth/revoke","pushed_authorization_request_endpoint":"https://pds.justin-stanley.com/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"protected_resources":["https://pds.justin-stanley.com"],"client_id_metadata_document_supported":true,"prompt_values_supported":["none","login","consent","select_account","create"]}"#;
991        let pds = "https://pds.justin-stanley.com";
992
993        let prm: Value = serde_json::from_str(REAL_PROTECTED_RESOURCE).unwrap();
994        let issuer = validate_protected_resource(&prm, pds).unwrap();
995        assert_eq!(issuer, pds);
996
997        let asm: Value = serde_json::from_str(REAL_AUTHORIZATION_SERVER).unwrap();
998        // Both client shapes must be accepted: the localhost dev client
999        // authenticates with `none`, production with `private_key_jwt`.
1000        for method in ["none", "private_key_jwt"] {
1001            let server = validate_authorization_server(&asm, &issuer, pds, method)
1002                .unwrap_or_else(|e| panic!("a real PDS was rejected for {method}: {e:#}"));
1003            assert_eq!(
1004                server.par_endpoint,
1005                "https://pds.justin-stanley.com/oauth/par"
1006            );
1007            assert_eq!(
1008                server.authorization_endpoint,
1009                "https://pds.justin-stanley.com/oauth/authorize"
1010            );
1011            assert_eq!(
1012                server.token_endpoint,
1013                "https://pds.justin-stanley.com/oauth/token"
1014            );
1015            // The real PDS advertises revocation, so sign-out can actually reach
1016            // it. Asserted against the frozen real document rather than assumed:
1017            // an optional field that happens to be absent everywhere we deploy
1018            // would make the revocation path dead code that still passes its own
1019            // unit tests.
1020            assert_eq!(
1021                server.revocation_endpoint.as_deref(),
1022                Some("https://pds.justin-stanley.com/oauth/revoke")
1023            );
1024        }
1025    }
1026
1027    // ── the WIRING, which is where the mix-up defence actually lives ─────────
1028
1029    fn prm_naming(issuer: &str) -> serde_json::Value {
1030        json!({ "resource": PDS, "authorization_servers": [issuer] })
1031    }
1032
1033    /// **The expected issuer comes from the PDS's document, never from the
1034    /// authorization server's own claim.**
1035    ///
1036    /// Feeding the AS document's `issuer` in as the expected value turns the
1037    /// mix-up check into `declared == declared` — and that mutation passed all
1038    /// 575 tests, because every test of the check sat on
1039    /// `validate_authorization_server` and nothing exercised how `discover`
1040    /// called it.
1041    #[test]
1042    fn the_expected_issuer_comes_from_the_protected_resource_document() {
1043        // The PDS names ISS. The metadata claims to be a DIFFERENT issuer — and
1044        // is internally consistent about it, endpoints and all.
1045        let other = "https://evil.example";
1046        let mut asm = as_metadata();
1047        asm["issuer"] = json!(other);
1048        for field in [
1049            "pushed_authorization_request_endpoint",
1050            "authorization_endpoint",
1051            "token_endpoint",
1052        ] {
1053            asm[field] = json!(format!("{other}/x"));
1054        }
1055
1056        let err = match resolve_documents(
1057            &prm_naming(ISS),
1058            &asm,
1059            &authorization_server_url(ISS),
1060            PDS,
1061            "none",
1062        ) {
1063            Err(err) => err,
1064            Ok(_) => panic!("a self-consistent impostor was accepted"),
1065        };
1066        assert!(
1067            format!("{err:#}").contains("issuer"),
1068            "failed for the wrong reason: {err:#}"
1069        );
1070    }
1071
1072    /// **The metadata must have been fetched from the issuer's own location.**
1073    ///
1074    /// `metadata.issuer == the URL we fetched from` is only a defence if the
1075    /// second fetch really went to the issuer. Building that URL from the PDS
1076    /// instead passed the whole suite.
1077    #[test]
1078    fn metadata_fetched_from_the_wrong_place_is_refused() {
1079        let err = match resolve_documents(
1080            &prm_naming(ISS),
1081            &as_metadata(),
1082            // What the mutation did: derive it from the PDS.
1083            &authorization_server_url(PDS),
1084            PDS,
1085            "none",
1086        ) {
1087            Err(err) => err,
1088            Ok(_) => panic!("metadata from the wrong origin was accepted"),
1089        };
1090        assert!(format!("{err:#}").contains("issuer's own"), "{err:#}");
1091
1092        // Fetched from the right place: accepted.
1093        resolve_documents(
1094            &prm_naming(ISS),
1095            &as_metadata(),
1096            &authorization_server_url(ISS),
1097            PDS,
1098            "none",
1099        )
1100        .expect("the honest pair must resolve");
1101    }
1102
1103    /// The second URL is built from the ISSUER, and the first from the PDS.
1104    #[test]
1105    fn the_discovery_urls_come_from_the_right_inputs() {
1106        assert_eq!(
1107            protected_resource_url("https://pds.example.com/xrpc/x").unwrap(),
1108            "https://pds.example.com/.well-known/oauth-protected-resource"
1109        );
1110        assert_eq!(
1111            authorization_server_url(ISS),
1112            format!("{ISS}/.well-known/oauth-authorization-server")
1113        );
1114    }
1115
1116    // ── the full discovery orchestration, with the fetch injected ────────────
1117
1118    /// Serve documents by URL and record every URL asked for.
1119    struct Fetcher {
1120        docs: std::collections::HashMap<String, serde_json::Value>,
1121        asked: std::sync::Mutex<Vec<String>>,
1122    }
1123
1124    impl Fetcher {
1125        fn new(pairs: &[(&str, serde_json::Value)]) -> Self {
1126            Self {
1127                docs: pairs
1128                    .iter()
1129                    .map(|(u, d)| ((*u).to_string(), d.clone()))
1130                    .collect(),
1131                asked: std::sync::Mutex::new(Vec::new()),
1132            }
1133        }
1134
1135        async fn get(&self, url: String) -> Result<serde_json::Value> {
1136            self.asked.lock().unwrap().push(url.clone());
1137            self.docs
1138                .get(&url)
1139                .cloned()
1140                .ok_or_else(|| anyhow::anyhow!("nothing served at {url}"))
1141        }
1142
1143        fn asked(&self) -> Vec<String> {
1144            self.asked.lock().unwrap().clone()
1145        }
1146    }
1147
1148    /// **The metadata is fetched from the ISSUER, not from the PDS.**
1149    ///
1150    /// This is the mutation that survived even after `resolve_documents` gained
1151    /// its origin check: the check made the mistake fail closed at runtime, but
1152    /// nothing exercised the code that chooses the URL. Now the fetcher records
1153    /// what was asked for, so a wrong derivation is visible directly.
1154    #[tokio::test]
1155    async fn the_metadata_is_fetched_from_the_issuers_own_location() {
1156        let fetcher = Fetcher::new(&[
1157            (
1158                &protected_resource_url(PDS).unwrap(),
1159                json!({ "resource": PDS, "authorization_servers": [ISS] }),
1160            ),
1161            (&authorization_server_url(ISS), as_metadata()),
1162        ]);
1163
1164        let server = discover_with(|url| fetcher.get(url), PDS, "none", None)
1165            .await
1166            .expect("the honest pair must resolve");
1167        assert_eq!(server.issuer, ISS);
1168
1169        assert_eq!(
1170            fetcher.asked(),
1171            vec![
1172                protected_resource_url(PDS).unwrap(),
1173                authorization_server_url(ISS),
1174            ],
1175            "discovery asked for the wrong locations, or in the wrong order"
1176        );
1177    }
1178
1179    /// A PDS that repoints an EXISTING grant at a new authorization server is
1180    /// refused — the check the revocation path did not have.
1181    ///
1182    /// Every other validation passes here: the pair is entirely self-consistent,
1183    /// `resource` equals the PDS origin, exactly one AS is named, and the
1184    /// metadata's `issuer` matches the location it was served from. The only
1185    /// thing wrong is that it is not the issuer the grant belongs to, and the
1186    /// only check that notices is this one.
1187    ///
1188    /// Why it mattered most on revocation: `revoke_params` prefers the REFRESH
1189    /// token, and `bounded_then_delete` drops the local row whether or not the
1190    /// revocation succeeded — so a repointed PDS would receive the refresh token
1191    /// while the real authorization server was never told, leaving a live grant
1192    /// that the app can no longer revoke. "Sign out everywhere" would silently
1193    /// mean the opposite.
1194    #[tokio::test]
1195    async fn a_repointed_pds_cannot_move_an_existing_grant() {
1196        // A FULLY self-consistent impostor: the attacker hosts every endpoint on
1197        // its own origin, which is what the co-location check requires. Moving
1198        // only `issuer` would be caught by a different check and would prove
1199        // nothing about this one.
1200        let attacker = "https://as.attacker.example";
1201        let mut moved = as_metadata();
1202        moved["issuer"] = json!(attacker);
1203        moved["pushed_authorization_request_endpoint"] = json!(format!("{attacker}/par"));
1204        moved["authorization_endpoint"] = json!(format!("{attacker}/authorize"));
1205        moved["token_endpoint"] = json!(format!("{attacker}/token"));
1206        moved["revocation_endpoint"] = json!(format!("{attacker}/revoke"));
1207
1208        let fetcher = Fetcher::new(&[
1209            (
1210                &protected_resource_url(PDS).unwrap(),
1211                json!({ "resource": PDS, "authorization_servers": [attacker] }),
1212            ),
1213            (&authorization_server_url(attacker), moved),
1214        ]);
1215
1216        // Sanity: with no prior issuer (a first login) this pair is legitimate.
1217        discover_with(|url| fetcher.get(url), PDS, "none", None)
1218            .await
1219            .expect("a self-consistent pair must resolve when there is no grant yet");
1220
1221        let err = discover_with(|url| fetcher.get(url), PDS, "none", Some(ISS))
1222            .await
1223            .expect_err("a grant issued by ISS must not follow the PDS to a new issuer");
1224        let msg = err.to_string();
1225        assert!(
1226            msg.contains(ISS) || msg.contains(attacker),
1227            "the error should name the issuers it compared, got: {msg}"
1228        );
1229    }
1230
1231    /// A PDS that names someone else's authorization server gets that server's
1232    /// document fetched — and the issuer carried forward is the one the PDS
1233    /// named, not the one the document claims.
1234    #[tokio::test]
1235    async fn the_issuer_carried_forward_is_the_one_the_pds_named() {
1236        let other = "https://other.example";
1237        let mut impostor = as_metadata();
1238        impostor["issuer"] = json!(other);
1239
1240        let fetcher = Fetcher::new(&[
1241            (
1242                &protected_resource_url(PDS).unwrap(),
1243                json!({ "resource": PDS, "authorization_servers": [ISS] }),
1244            ),
1245            // Served at the URL derived from the issuer the PDS named, but
1246            // claiming to be a different issuer.
1247            (&authorization_server_url(ISS), impostor),
1248        ]);
1249
1250        let err = match discover_with(|url| fetcher.get(url), PDS, "none", None).await {
1251            Err(err) => err,
1252            Ok(_) => panic!("a document claiming a different issuer was accepted"),
1253        };
1254        assert!(format!("{err:#}").contains("issuer"), "{err:#}");
1255    }
1256
1257    /// A fetch failure on either document fails the discovery rather than
1258    /// proceeding with half a picture.
1259    #[tokio::test]
1260    async fn a_missing_document_fails_the_discovery() {
1261        // Only the protected-resource document exists.
1262        let fetcher = Fetcher::new(&[(
1263            &protected_resource_url(PDS).unwrap(),
1264            json!({ "resource": PDS, "authorization_servers": [ISS] }),
1265        )]);
1266        assert!(discover_with(|url| fetcher.get(url), PDS, "none", None)
1267            .await
1268            .is_err());
1269
1270        // Neither exists.
1271        let empty = Fetcher::new(&[]);
1272        assert!(discover_with(|url| empty.get(url), PDS, "none", None)
1273            .await
1274            .is_err());
1275    }
1276}