Skip to main content

didbot_serve/oauth/
par.rs

1//! Pushed authorization requests (RFC 9126), and where a sign-in is decided.
2//!
3//! **One seam, and this is it.** PAR is the earliest point the client is
4//! known — the first request that both names a `client_id` and can be
5//! validated against fetched metadata — and it is where the decision record
6//! an agent later answers is written. Two inputs reach that verdict and
7//! nothing else does: `plan/policy.md`'s deny-only tree, asked about a
8//! `didbot_policy::Subject::Grant`, and `plan/scope-policy.md`'s ceiling,
9//! applied by intersection.
10//!
11//! Client admission is one of those denials rather than a seam beside them.
12//! `plan/app-allowlist.md`'s "refuse at the pushed authorization request" is
13//! a `denyClient`-shaped policy in the tree, and it is asked about before
14//! anything else here: `didbot_policy::Subject::Grant` names a `client_id`
15//! and the requested scope but no account, so the gate's answer cannot
16//! depend on `login_hint` and does not wait for it. A client the gate
17//! refuses outright gets no decision record and no `request_uri` — see
18//! [`ParError::ClientRefused`] — the opposite of every other denial this
19//! module writes, and deliberately so: the record would have to say so to
20//! be useful, and saying so to an unauthenticated caller is exactly what
21//! `plan/app-allowlist.md` keeps the operator's to know.
22//!
23//! The record is never judged again. `GET /oauth/authorize` and
24//! `bot.did.approveAuthorization` read the record this module wrote — see
25//! [`crate::oauth::decision`] — so a policy edited after a push never
26//! changes an answer already given. It does bind the token that answer
27//! would lead to: `POST /oauth/token` asks the gate again before issuing
28//! one — see `crate::oauth::token::GateAdmission`.
29//!
30//! **`login_hint` naming nobody is not a third seam either — it is not a
31//! seam at all.** This endpoint is unauthenticated, and no observable
32//! answer may depend on whether an account exists: an account this
33//! deployment hosts, an account `plan/policy.md`'s gate would narrow or
34//! deny, an account whose own pending bound is already full, and a
35//! `login_hint` matching nobody here all get the same `201`, or pushing one
36//! request per candidate handle — or enough variously-spelled requests for
37//! one candidate — is a directory of this deployment's accounts. The one
38//! `403` this endpoint answers with is the server's own `pending_total`
39//! bound, which is identity-independent by construction: true for every
40//! caller alike, so answering it truthfully leaks nothing. See
41//! `push_for_unresolved_hint` (private, this module) for what a nonexistent
42//! hint gets instead of a decision record, and why an *account-keyed*
43//! bound on it — an earlier version of this fix — reopened the hole by a
44//! different door.
45//!
46//! **An account at its own bound displaces its oldest undecided record.**
47//! `login_hint` is whatever the caller writes, so an account's eight slots
48//! are fillable by a stranger. Dropping the newest push let that stranger
49//! keep an account from signing in to anything; displacing the oldest keeps
50//! the bound and keeps the account's freshest attempt answerable — including
51//! when this server's own `pending_total` is full, since a push that gives a
52//! slot back before it takes one does not grow the store. See
53//! [`crate::oauth::decision::DecisionStore::put`].
54
55use std::collections::HashMap;
56use std::sync::RwLock;
57
58use rand::rngs::OsRng;
59use rand::TryRngCore;
60use time::{Duration, OffsetDateTime};
61
62use crate::oauth::authorize::{ResponseMode, ScopePolicy};
63use crate::oauth::client_auth::{ClientAssertionVerifier, ClientAuthError, ClientCredentials};
64use crate::oauth::client_metadata::{ClientKey, ClientMetadataDocument};
65use crate::oauth::consent::{self, ConsentStore};
66use crate::oauth::decision::{
67    verdict_for, ClientSelf, DecisionRecord, DecisionRefused, DecisionState, DecisionStore,
68    Verdict, CEILING_RULE,
69};
70use crate::oauth::dpop_seam::DpopThumbprint;
71use crate::oauth::permission_set::PermissionSets;
72use crate::oauth::scope::{Scope, ScopeSet};
73
74/// How long a pushed request stays redeemable. RFC 9126 recommends 60
75/// seconds; this server gives a little more room for the agent-driven
76/// browser `plan/oauth.md` describes to load `authorize` before the request
77/// URI it just pushed goes stale.
78pub const REQUEST_URI_TTL: Duration = Duration::minutes(2);
79
80/// Random bytes a `request_uri` is drawn from, before encoding.
81const REQUEST_URI_BYTES: usize = 32;
82
83/// The prefix RFC 9126 requires: "the URI ... MUST include the
84/// `urn:ietf:params:oauth:request_uri` prefix".
85const REQUEST_URI_PREFIX: &str = "urn:ietf:params:oauth:request_uri:";
86
87/// The longest `login_hint` a push may carry, in bytes.
88///
89/// A hint is a handle or a DID. atproto's specifications cap a handle at 253
90/// characters and a DID at 2048, so nothing longer names an account.
91pub const MAX_LOGIN_HINT_BYTES: usize = 2048;
92
93/// The longest `state` a push may carry, in bytes.
94///
95/// `state` is the client's own random token, which it checks when the
96/// redirect comes back. jacquard-oauth and `@atproto/oauth-client` both send
97/// 16 random bytes as 22 characters. A bound over forty times that reaches
98/// no real client, and caps what this server keeps for a stranger's push.
99pub const MAX_STATE_BYTES: usize = 1024;
100
101/// How long an S256 `code_challenge` is: the unpadded base64url encoding of
102/// a SHA-256 digest (RFC 7636 §4.2).
103const S256_CHALLENGE_LENGTH: usize = 43;
104
105/// One pushed authorization request, as pushed.
106#[derive(Debug, Clone)]
107pub struct PushedRequest {
108    /// The client that pushed it, exactly as presented — `authorize` and
109    /// `token` both re-check this against what they are given, rather than
110    /// trusting a `request_uri` to imply it.
111    pub client_id: String,
112    /// The document `client_id` resolved to at push time, and its content
113    /// key. Carried through rather than re-fetched, so a document that
114    /// changes between PAR and `authorize` cannot smuggle a different
115    /// redirect URI or capability into a request that was already admitted.
116    pub client_key: ClientKey,
117    /// The resolved document itself, carried through for the same reason.
118    pub client_metadata: ClientMetadataDocument,
119    /// Where an authorization code may be delivered; checked against
120    /// `client_metadata.redirect_uris` at push time and again at `token`.
121    pub redirect_uri: String,
122    /// The scope requested, before any ceiling narrows it, with each
123    /// `include:` replaced by what its permission set grants.
124    pub scope: ScopeSet,
125    /// The client's own opaque state, echoed back unexamined.
126    pub state: Option<String>,
127    /// Where the authorization response carries its parameters.
128    pub response_mode: ResponseMode,
129    /// PKCE, mandatory for a public client and the norm for atproto OAuth.
130    pub code_challenge: String,
131    /// The PKCE method the challenge was computed with; only `S256` is
132    /// accepted.
133    pub code_challenge_method: String,
134    /// The account this authorization is for. atproto's own extension:
135    /// `login_hint` names the at-identifier the client wants to sign in as.
136    pub login_hint: Option<String>,
137    /// The account `login_hint` resolved to, looked up here rather than at
138    /// `authorize`: the decision record this push mints names an account.
139    /// A `login_hint` naming nobody never reaches this struct at all — see
140    /// [`push_authorization_request`]'s own doc for what it gets instead.
141    pub did: String,
142    /// The RFC 7638 thumbprint of the key the push's DPoP proof was signed
143    /// with: the request's `dpop_jkt` (RFC 9449 §10.1). The code it leads to
144    /// is exchanged only under the same key.
145    pub dpop_jkt: DpopThumbprint,
146    /// When this pushed request must be redeemed by, per [`REQUEST_URI_TTL`].
147    pub expires_at: OffsetDateTime,
148}
149
150/// Why a pushed request could not be accepted.
151#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
152pub enum ParError {
153    /// A required OAuth parameter was absent.
154    #[error("`{0}` is a required parameter")]
155    MissingParameter(&'static str),
156    /// `response_type` named something other than `code`.
157    #[error("only `code` is accepted for response_type")]
158    UnsupportedResponseType,
159    /// `response_mode` named a mode this server does not answer in.
160    #[error("response_mode `{0}` is not one this server answers in")]
161    UnsupportedResponseMode(String),
162    /// `redirect_uri` is not one the client's own metadata declares.
163    #[error("redirect_uri `{0}` is not one this client's metadata declares")]
164    RedirectUriNotRegistered(String),
165    /// `code_challenge_method` named something other than `S256`.
166    #[error("only `S256` is accepted for code_challenge_method, got `{0}`")]
167    UnsupportedCodeChallengeMethod(String),
168    /// `code_challenge` is not 43 base64url characters, which every S256
169    /// challenge is.
170    #[error("code_challenge must be the 43 base64url characters of an S256 digest")]
171    MalformedCodeChallenge,
172    /// A parameter is longer than any value a client sends for it; see
173    /// [`MAX_STATE_BYTES`] and [`MAX_LOGIN_HINT_BYTES`].
174    #[error("`{field}` is longer than the {max} bytes this server accepts")]
175    TooLong {
176        /// The parameter.
177        field: &'static str,
178        /// Its bound, in bytes.
179        max: usize,
180    },
181    /// `scope` did not parse as the atproto scope grammar.
182    #[error("scope did not parse: {0}")]
183    BadScope(#[from] crate::oauth::scope::ScopeParseError),
184    /// `client_id` is not in the one spelling a policy matches; see
185    /// `didbot_policy::ClientId`.
186    #[error(transparent)]
187    ClientId(#[from] didbot_pds::policy::ClientIdError),
188    /// The client's metadata document could not be fetched or validated.
189    ///
190    /// One sentence for every way that can happen, naming nothing the fetch
191    /// found. `client_id` is a URL this unauthenticated caller chose, so a
192    /// message that distinguished "nothing answered" from "it redirected"
193    /// from "the body was too large" would answer questions about this
194    /// deployment's own network for whoever asked them — see
195    /// [`didbot_http::REFUSED_BLOCKS`] for the addresses
196    /// that are refused before the fetch. The cause this variant carries is
197    /// written to the journal instead; see `crate::refusal`.
198    #[error("this client's metadata document could not be used")]
199    ClientMetadata(#[from] crate::oauth::client_metadata::ClientMetadataError),
200    /// The client did not authenticate the way its metadata says it does;
201    /// see [`crate::oauth::client_auth`].
202    #[error(transparent)]
203    ClientAuthentication(#[from] ClientAuthError),
204    /// `dpop_jkt` names a key other than the one the push's DPoP proof was
205    /// signed with (RFC 9449 §10.1).
206    #[error("dpop_jkt names a different key than this request's DPoP proof")]
207    DpopKeyMismatch,
208    /// `plan/policy.md`'s gate refuses every grant this `client_id` could
209    /// ever ask for here — a `denyClient` rule, or any rule shaped the same
210    /// way — independent of which account `login_hint` names.
211    /// `didbot_policy::Subject::Grant` carries no account field at all (see
212    /// `didbot_pds::policy::grant_subject`), so this judgment is a fact
213    /// about the client and the requested scope alone; checking it before
214    /// `login_hint` is even resolved changes nothing it could answer.
215    ///
216    /// Refused here, before a decision record exists, and the same way for
217    /// every `login_hint` alike — a real account, an account this gate
218    /// would otherwise grant, or one nobody has ever heard of, since the
219    /// judgment is made before any of them is looked at. That is what keeps
220    /// this refusal from being a second account-enumeration oracle beside
221    /// the one [`push_authorization_request`]'s own doc closes: it fires on
222    /// `client_id` and requested scope alone, so no `login_hint` value can
223    /// change which way it comes out. A grant this deployment *would* make
224    /// still gets the ordinary opaque-decision-record treatment
225    /// (`plan/oauth.md`'s "a pushed request is a decision record") — only a
226    /// client this gate refuses unconditionally skips minting one, because
227    /// there is nothing an account could approve.
228    #[error("this pushed authorization request was not accepted")]
229    ClientRefused,
230    /// This server already holds as many undecided records as it will at
231    /// once — server load, not a fact about any account, so it is safe to
232    /// answer identically to every caller. Only ever built from
233    /// [`DecisionRefused::TooMany`]: an account's own bound overflowing is
234    /// answered as a plain success instead, never as this — see
235    /// [`push_authorization_request`]'s own doc for why.
236    #[error(transparent)]
237    TooManyPending(#[from] DecisionRefused),
238}
239
240/// Where pushed requests live between `POST /oauth/par` and `authorize`.
241pub trait ParStore: Send + Sync {
242    /// Mints a fresh `request_uri`, drawn from the same random source and
243    /// format [`ParStore::push`] uses, without storing anything under it.
244    ///
245    /// `push_for_unresolved_hint` calls this rather than reaching for its
246    /// own random source: a `request_uri` this endpoint hands back is this
247    /// store's format to own, whether or not anything is ever stored under
248    /// it, so there is exactly one place that decides what one looks like.
249    fn mint(&self) -> String;
250    /// Stores a freshly pushed request, under a `request_uri` from
251    /// [`ParStore::mint`].
252    fn push(&self, request: PushedRequest) -> String;
253    /// The request named by `request_uri`, left where it is, expired or not.
254    /// `authorize` answers every load of the same `request_uri`, which RFC
255    /// 9126 §4 allows, and refuses an expired one to its client.
256    fn get(&self, request_uri: &str) -> Option<PushedRequest>;
257    /// Removes the request named by `request_uri`, handing it back if it has
258    /// not expired. What undoes a push whose decision record was refused or
259    /// displaced.
260    fn take(&self, request_uri: &str) -> Option<PushedRequest>;
261}
262
263/// The in-process PAR store.
264#[derive(Debug, Default)]
265pub struct MemoryParStore {
266    entries: RwLock<HashMap<String, PushedRequest>>,
267}
268
269impl MemoryParStore {
270    /// An empty store.
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    /// How many pushed requests this store still holds. Test use only.
276    #[cfg(test)]
277    pub(crate) fn len(&self) -> usize {
278        self.entries.read().unwrap_or_else(|p| p.into_inner()).len()
279    }
280}
281
282fn random_request_uri() -> String {
283    let mut bytes = [0u8; REQUEST_URI_BYTES];
284    OsRng
285        .try_fill_bytes(&mut bytes)
286        .expect("the OS random source is available");
287    format!("{REQUEST_URI_PREFIX}{}", hex::encode(bytes))
288}
289
290impl ParStore for MemoryParStore {
291    fn mint(&self) -> String {
292        random_request_uri()
293    }
294
295    fn push(&self, request: PushedRequest) -> String {
296        let uri = self.mint();
297        let now = OffsetDateTime::now_utc();
298        let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
299        // Nothing else removes a request whose decision was stored, so each
300        // one is swept here once it has expired, the same way
301        // `MemoryConsentStore::mint` sweeps its own map.
302        entries.retain(|_, pushed| now < pushed.expires_at);
303        entries.insert(uri.clone(), request);
304        uri
305    }
306
307    fn get(&self, request_uri: &str) -> Option<PushedRequest> {
308        self.entries
309            .read()
310            .unwrap_or_else(|p| p.into_inner())
311            .get(request_uri)
312            .cloned()
313    }
314
315    fn take(&self, request_uri: &str) -> Option<PushedRequest> {
316        let mut entries = self.entries.write().unwrap_or_else(|p| p.into_inner());
317        let request = entries.remove(request_uri)?;
318        if OffsetDateTime::now_utc() >= request.expires_at {
319            return None;
320        }
321        Some(request)
322    }
323}
324
325/// The raw wire fields `POST /oauth/par` accepts, before client metadata is
326/// resolved.
327#[derive(Debug, Clone)]
328pub struct ParRequest {
329    /// The HTTPS URL naming the client's metadata document.
330    pub client_id: String,
331    /// The OAuth response type. RFC 6749 §4.1.1 requires it, and only
332    /// `code` is accepted.
333    pub response_type: Option<String>,
334    /// Where the authorization response carries its parameters; see
335    /// [`ResponseMode`]. Absent is `query`.
336    pub response_mode: Option<String>,
337    /// Where an authorization code should be delivered.
338    pub redirect_uri: Option<String>,
339    /// The requested scope, unparsed.
340    pub scope: Option<String>,
341    /// The client's own opaque state, echoed back unexamined.
342    pub state: Option<String>,
343    /// PKCE code challenge.
344    pub code_challenge: Option<String>,
345    /// PKCE code challenge method; only `S256` is accepted.
346    pub code_challenge_method: Option<String>,
347    /// The at-identifier the client wants to sign in as.
348    pub login_hint: Option<String>,
349    /// The client assertion a confidential client authenticates with.
350    pub credentials: ClientCredentials,
351    /// The key the client names for exchanging the code, when it names one
352    /// (RFC 9449 §10).
353    pub dpop_jkt: Option<String>,
354    /// The thumbprint of the key the push's DPoP proof proved possession of.
355    pub proof_key: DpopThumbprint,
356}
357
358/// The seams PAR reaches to work out what it would grant, bundled so
359/// [`push_authorization_request`] takes one argument rather than seven.
360///
361/// None of these is PAR's own: the ceiling is `plan/scope-policy.md`'s, the
362/// gate is `plan/policy.md`'s, and the two stores are where the answer goes.
363/// This module calls them in the order below and decides nothing itself.
364///
365/// They are the whole of what judges a sign-in. There is no fourth seam and
366/// no second moment — see this module's own doc. The assertion verifier
367/// judges nothing about the grant: it checks that a confidential client is
368/// who it says it is.
369pub struct DecisionSeams<'a> {
370    /// The ceiling this account may ever hold from this client.
371    pub scope_policy: &'a dyn ScopePolicy,
372    /// Where the one-time reference an approval carries is minted.
373    pub consent_store: &'a dyn ConsentStore,
374    /// Where the record itself goes.
375    pub decisions: &'a dyn DecisionStore,
376    /// `plan/policy.md`'s deny-only gate, asked about the grant.
377    pub gate: &'a dyn didbot_pds::policy::PolicyGate,
378    /// Where the *ceiling*'s narrowing or refusal is written down,
379    /// payload-free. A policy's refusal is written by
380    /// [`DecisionSeams::gate`] itself, so this must be the same log that
381    /// gate holds or the two halves of one deployment's answer land in
382    /// different places.
383    pub evaluation_log: &'a didbot_pds::evaluation_log::EvaluationLog,
384    /// This deployment's own DID, for the subject the gate judges.
385    pub pds_did: &'a str,
386    /// This deployment's own hostname, for the same subject.
387    pub pds_hostname: &'a str,
388    /// Whether this deployment fetches a loopback `http://` client metadata
389    /// document; see [`crate::oauth::client_metadata::LoopbackClients`].
390    pub loopback_clients: crate::oauth::client_metadata::LoopbackClients,
391    /// Where a confidential client's assertion is checked; see
392    /// [`crate::oauth::client_auth`].
393    pub client_assertions: &'a ClientAssertionVerifier,
394    /// This deployment's issuer, which a client assertion's `aud` must name.
395    pub issuer: &'a str,
396    /// Where an `include:` scope's permission set is resolved; see
397    /// [`crate::oauth::permission_set`].
398    pub permission_sets: &'a PermissionSets,
399}
400
401/// Handles one pushed authorization request end to end: resolves and
402/// validates the client's metadata, checks the request against it, resolves
403/// the account it names, decides what it would grant, and stores both the
404/// request and the decision.
405///
406/// Returns the minted `request_uri` and its lifetime — for every
407/// well-formed push alike, whatever it goes on to answer. `resolve` turns
408/// `login_hint` into an account DID — the HTTP layer's own
409/// `didbot_pds::Registry` lookup, passed in because this module holds no
410/// registry — and a hint that resolves to nobody gets the *same* `201`, not
411/// a failure: see `push_for_unresolved_hint` (private, this module) for
412/// what it gets instead of a decision record, and why. This is the fix for
413/// this endpoint's own
414/// account-enumeration hole — unauthenticated, and the earliest point a
415/// `login_hint` is even looked at — which a distinguishable refusal here
416/// reopened no matter how carefully worded, since a caller only has to
417/// compare it against what a *resolvable* hint gets, and nothing here ever
418/// refuses one of those. `login_hint` is mandatory here — RFC 9126 itself
419/// does not require one, but this profile addresses the decision record it
420/// mints to an account, and there is no other field naming which one; a
421/// missing hint is [`ParError::MissingParameter`], the same as any other
422/// field this endpoint cannot proceed without, since every account alike
423/// would refuse the request regardless of which one asked.
424///
425/// **A `403` must depend on server load, never on an account.** A real
426/// account whose own `pending_per_account` bound is already full is *not*
427/// refused — that would resurrect the same oracle as a `login_hint` naming
428/// nobody, just aimed at an account that exists rather than one that does
429/// not (push enough candidate spellings and time which ones eventually
430/// answer `403`). Its push is stored, and the account's oldest undecided
431/// record makes way for it
432/// ([`crate::oauth::decision::DecisionStore::put`]). That record's pushed
433/// request is taken back out of [`ParStore`] and its
434/// [`crate::oauth::consent::ConsentReference`] discarded from
435/// [`crate::oauth::consent::ConsentStore`] — both undone, or an
436/// unauthenticated caller could leak one live consent entry per push
437/// against any account it names, forever. An `authorize` for the displaced
438/// request then fails exactly as it would for a `request_uri` that expired.
439/// The only `403` this endpoint answers with is
440/// [`crate::oauth::decision::DecisionRefused::TooMany`] — the server itself
441/// out of room, true for every caller identically — and a displacement is
442/// logged at `info`, sampled, so an operator can still see a flood
443/// happening even though the caller cannot tell it apart from an ordinary
444/// push.
445///
446/// The decision is minted in the same call, and the two are stored
447/// together on the path that stores anything at all: if the record is
448/// refused by the server-wide bound the pushed request is taken straight
449/// back out, so a `request_uri` never exists without the decision that
450/// explains it — and neither does a displaced one.
451pub async fn push_authorization_request(
452    http: &reqwest::Client,
453    cache: &dyn crate::oauth::client_metadata::ClientMetadataCache,
454    store: &dyn ParStore,
455    request: ParRequest,
456    resolve: &(dyn Fn(&str) -> Option<String> + Send + Sync),
457    seams: &DecisionSeams<'_>,
458) -> Result<(String, Duration), ParError> {
459    // Checked before the client's document is fetched: it needs no document,
460    // and a request refused here costs this server no outbound request.
461    match request.response_type.as_deref() {
462        None => return Err(ParError::MissingParameter("response_type")),
463        Some("code") => {}
464        Some(_) => return Err(ParError::UnsupportedResponseType),
465    }
466    let response_mode = match request.response_mode.as_deref() {
467        None => ResponseMode::default(),
468        Some(named) => ResponseMode::named(named)
469            .ok_or_else(|| ParError::UnsupportedResponseMode(named.to_owned()))?,
470    };
471    // Both are kept with the pushed request, and `state` with its pending
472    // consent too, so both are bounded before anything else is spent.
473    at_most("state", request.state.as_deref(), MAX_STATE_BYTES)?;
474    at_most(
475        "login_hint",
476        request.login_hint.as_deref(),
477        MAX_LOGIN_HINT_BYTES,
478    )?;
479    let client_id = didbot_pds::policy::ClientId::parse(&request.client_id)?;
480
481    let scope_str = request.scope.ok_or(ParError::MissingParameter("scope"))?;
482    let (scope, forms) = ScopeSet::read(&scope_str)?;
483    let login_hint = request
484        .login_hint
485        .ok_or(ParError::MissingParameter("login_hint"))?;
486
487    // `didbot_policy::Subject::Grant` names a `client_id` and the requested
488    // scope but no account (`didbot_pds::policy::grant_subject`), so this is
489    // a fact about the client alone and is asked about before `login_hint`
490    // is even resolved — see [`ParError::ClientRefused`]. Built once and
491    // reused by [`decide`] for its own row in the evaluation log, rather
492    // than rebuilt there, so a caller of this function is not the only
493    // thing this judgment is asked on behalf of exactly once.
494    //
495    // **Asked before the document is fetched.** Everything it judges —
496    // `client_id` and the requested scope — is in the request itself, and
497    // the fetch is an outbound request to a host this unauthenticated caller
498    // named. A client the gate refuses should not cost this deployment one.
499    let atoms: Vec<String> = scope.0.iter().map(ToString::to_string).collect();
500    let borrowed: Vec<&str> = atoms.iter().map(String::as_str).collect();
501    let subject =
502        didbot_pds::policy::grant_subject(seams.pds_did, seams.pds_hostname, &client_id, &borrowed);
503    if !matches!(
504        seams.gate.judge(&subject),
505        didbot_pds::policy::Outcome::Allow
506    ) {
507        // Nothing downstream has anything to approve, and no account was
508        // ever looked at, so nothing is minted.
509        return Err(ParError::ClientRefused);
510    }
511
512    // The document is built from, or fetched at, the id the client sent: the
513    // profile has a local client's document carry that exact string.
514    let (document, client_key) = crate::oauth::client_metadata::resolve_client(
515        http,
516        cache,
517        &request.client_id,
518        seams.loopback_clients,
519    )
520    .await?;
521
522    // The client proves who it is before anything it asked for is read. A
523    // confidential client's assertion is spent here, and the login this push
524    // leads to stays bound to the key that signed it.
525    let client_auth_key =
526        seams
527            .client_assertions
528            .authenticate(&document, &request.credentials, seams.issuer)?;
529
530    // RFC 9449 §10.1: a push that names its key in `dpop_jkt` names the key
531    // its proof was signed with. Checked where the reference provider checks
532    // it, once the client has authenticated and before the rest of the
533    // request is read.
534    if request
535        .dpop_jkt
536        .as_deref()
537        .is_some_and(|named| named != request.proof_key.0)
538    {
539        return Err(ParError::DpopKeyMismatch);
540    }
541
542    let redirect_uri = request
543        .redirect_uri
544        .ok_or(ParError::MissingParameter("redirect_uri"))?;
545    if !document.admits_redirect_uri(&redirect_uri) {
546        return Err(ParError::RedirectUriNotRegistered(redirect_uri));
547    }
548
549    let code_challenge = request
550        .code_challenge
551        .ok_or(ParError::MissingParameter("code_challenge"))?;
552    let code_challenge_method = request
553        .code_challenge_method
554        .ok_or(ParError::MissingParameter("code_challenge_method"))?;
555    if code_challenge_method != "S256" {
556        return Err(ParError::UnsupportedCodeChallengeMethod(
557            code_challenge_method,
558        ));
559    }
560    if !is_s256_challenge(&code_challenge) {
561        return Err(ParError::MalformedCodeChallenge);
562    }
563
564    // An atom in a form the permission spec does not define was read as well
565    // as it could be. Said once the client has shown who it is, so that a
566    // caller naming no real client writes nothing at `warn`.
567    for (atom, warning) in &forms {
568        tracing::warn!(
569            client_id = client_id.as_str(),
570            atom = atom.as_str(),
571            %warning,
572            "a pushed authorization request asked for a scope in a form the permission spec does not define"
573        );
574    }
575
576    // Each `include:` becomes the atoms its permission set grants, before
577    // the ceiling sees the request. Resolved before `login_hint` is, so
578    // that how long a push takes depends on no account.
579    let scope = seams.permission_sets.expand(&scope).await;
580
581    let now = OffsetDateTime::now_utc();
582    let expires_at = now + REQUEST_URI_TTL;
583
584    let Some(did) = resolve(&login_hint) else {
585        return push_for_unresolved_hint(store, seams.decisions);
586    };
587
588    let verdict = decide(&did, client_id.as_str(), &scope, &subject, seams);
589
590    let pushed = PushedRequest {
591        client_id: request.client_id.clone(),
592        client_key: client_key.clone(),
593        client_metadata: document.clone(),
594        redirect_uri: redirect_uri.clone(),
595        scope: scope.clone(),
596        state: request.state.clone(),
597        response_mode,
598        code_challenge: code_challenge.clone(),
599        code_challenge_method: code_challenge_method.clone(),
600        login_hint: Some(login_hint),
601        did: did.clone(),
602        dpop_jkt: request.proof_key.clone(),
603        expires_at,
604    };
605    let uri = store.push(pushed);
606
607    // A denied request gets a record and no reference: there is nothing to
608    // approve, so nothing that could approve it is ever minted.
609    //
610    // What the reference carries is the *verdict's* scope, not the request:
611    // the agent is shown `granted` and approves that, so that is what the
612    // code and the login it becomes are issued at. A ceiling loosened
613    // afterwards cannot hand back an atom the record named as `cut`.
614    let token = (!verdict.is_deny()).then(|| {
615        consent::begin(
616            seams.consent_store,
617            uri.clone(),
618            did.clone(),
619            request.client_id.clone(),
620            verdict.granted(&scope),
621            redirect_uri,
622            request.state,
623            code_challenge,
624            code_challenge_method,
625            client_auth_key,
626            request.proof_key,
627        )
628    });
629
630    let record = DecisionRecord {
631        request_uri: uri.clone(),
632        account: did.clone(),
633        client_origin: origin_of(&request.client_id),
634        client_key,
635        first_time: false,
636        client_self: ClientSelf::of(&document),
637        requested: scope,
638        verdict,
639        // Cloned rather than moved: `put` may refuse the record, and both
640        // undo branches below still need the reference `consent::begin`
641        // already minted, to discard it — see those branches' own doc.
642        token: token.clone(),
643        created_at: now,
644        expires_at,
645        state: DecisionState::Pending,
646        sequence: 0,
647    };
648    match seams.decisions.put(record) {
649        Ok(stored) => {
650            if let Some(displaced) = stored.displaced {
651                // This account was at its own bound, so its oldest undecided
652                // record made way for this one. Both of that record's own
653                // stores are undone here, for the reason the refusal branch
654                // below undoes them: a pushed request nothing will redeem and
655                // a reference nothing will present are memory this server
656                // holds for the rest of the process's life, and this endpoint
657                // is unauthenticated. An operator sees it in the log, sampled
658                // so logging is not itself something a flood can amplify.
659                let _ = store.take(&displaced.request_uri);
660                if let Some(reference) = &displaced.token {
661                    seams.consent_store.discard(reference);
662                }
663                log_displaced_push(&did);
664            }
665            Ok((uri, REQUEST_URI_TTL))
666        }
667        Err(refused) => {
668            // Server state, identity-independent: the one `403` this
669            // endpoint answers with. Undoing both stores fully, not just
670            // `ParStore` — a reference `MemoryConsentStore` never sees
671            // consumed is a reference it holds for the rest of the
672            // process's life otherwise, and this endpoint is
673            // unauthenticated.
674            let _ = store.take(&uri);
675            if let Some(reference) = &token {
676                seams.consent_store.discard(reference);
677            }
678            SERVER_FULL_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
679            Err(ParError::TooManyPending(refused))
680        }
681    }
682}
683
684/// Refuses `value` if it is longer than `max` bytes.
685fn at_most(field: &'static str, value: Option<&str>, max: usize) -> Result<(), ParError> {
686    match value {
687        Some(value) if value.len() > max => Err(ParError::TooLong { field, max }),
688        _ => Ok(()),
689    }
690}
691
692/// Whether `challenge` has the shape of an S256 code challenge: exactly
693/// [`S256_CHALLENGE_LENGTH`] characters of the base64url alphabet.
694fn is_s256_challenge(challenge: &str) -> bool {
695    challenge.len() == S256_CHALLENGE_LENGTH
696        && challenge
697            .bytes()
698            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
699}
700
701/// How often [`log_displaced_push`] actually emits a log line, rather than
702/// only counting one.
703///
704/// The account whose bound was hit is already known to be at it, so an
705/// attacker who has pushed it there once can trigger this branch again for
706/// free, from as many caller addresses as it likes — logging every one
707/// would make the log itself the thing a flood amplifies. This is one
708/// process-wide counter rather than one per account: a per-account count
709/// would be the same unbounded-growth shape this fix exists to close, just
710/// moved one field over.
711const DISPLACED_PUSH_LOG_SAMPLE: u64 = 100;
712
713/// How many undecided records have been displaced past an account's own
714/// pending bound, this process's lifetime; see
715/// [`DISPLACED_PUSH_LOG_SAMPLE`].
716static DISPLACED_PUSH_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
717
718/// How many pushes named a `login_hint` this deployment resolved to no
719/// account; see [`push_for_unresolved_hint`].
720static UNRESOLVED_HINT_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
721
722/// How many pushes this server's own `pending_total` refused; see
723/// [`ParError::TooManyPending`].
724static SERVER_FULL_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
725
726/// How many pushes have not left an answerable decision record behind, this
727/// process's lifetime, by reason.
728///
729/// **What an operator reads instead of the log.** Every one of these answers
730/// the caller `201` except `server_full`, because this endpoint is
731/// unauthenticated and no observable answer may depend on an account — see
732/// [`push_authorization_request`]. That is what makes a flood aimed at one
733/// account invisible from outside, and the `info` line these count alongside
734/// is sampled so that logging cannot itself be flooded. `GET /health`
735/// carries them under `oauthPushes`.
736#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
737pub struct DroppedPushes {
738    /// Pushes past an account's own bound, each of which took that account's
739    /// oldest undecided record with it.
740    pub displaced: u64,
741    /// Pushes whose `login_hint` named no account here.
742    pub unresolved_hint: u64,
743    /// Pushes refused because this server holds as many undecided records as
744    /// it will.
745    pub server_full: u64,
746}
747
748/// [`DroppedPushes`] as it stands now.
749pub fn dropped_pushes() -> DroppedPushes {
750    use std::sync::atomic::Ordering;
751    DroppedPushes {
752        displaced: DISPLACED_PUSH_COUNT.load(Ordering::Relaxed),
753        unresolved_hint: UNRESOLVED_HINT_COUNT.load(Ordering::Relaxed),
754        server_full: SERVER_FULL_COUNT.load(Ordering::Relaxed),
755    }
756}
757
758/// Logs a displaced record at `info`, sampled to one in
759/// [`DISPLACED_PUSH_LOG_SAMPLE`] so the log line itself cannot be flooded.
760fn log_displaced_push(did: &str) {
761    use std::sync::atomic::Ordering;
762    let seen = DISPLACED_PUSH_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
763    if seen % DISPLACED_PUSH_LOG_SAMPLE == 1 {
764        tracing::info!(
765            %did,
766            seen,
767            sampled_one_in = DISPLACED_PUSH_LOG_SAMPLE,
768            "a push past this account's own pending bound displaced its oldest undecided record"
769        );
770    }
771}
772
773/// What `login_hint` naming nobody gets instead of
774/// [`push_authorization_request`]'s ordinary decision record: the identical
775/// success this endpoint hands back for a real account, and nothing an
776/// unauthenticated caller — or this store — can act on.
777///
778/// **No account state, ever — that is the whole fix, and the whole reason
779/// this stores nothing at all.** An earlier version of this function put a
780/// placeholder [`DecisionRecord`] through [`DecisionStore::put`], keyed on
781/// the raw `login_hint` string, so a nonexistent hint would cost the same
782/// `pending_per_account` slot a real account's push would. That reopened
783/// the hole a different way: `resolve` folds every spelling of a *real*
784/// account — a handle, its DID, one with a redundant `:port` — into the one
785/// DID that names it, so a real account's pending bucket is one bucket
786/// however it is addressed, while every distinct spelling of a nonexistent
787/// one is its own bucket forever. Push eight variously-spelled requests for
788/// one candidate and a ninth: a real account's ninth answers differently
789/// from its first eight, and a made-up one's ninth never does — the
790/// aliasing itself is the oracle, no matter how the two buckets' *refusals*
791/// are worded. Worse, since nothing bounds how many distinct invented
792/// strings exist, that placeholder was also a new denial-of-service surface
793/// past the account-existence one: a handful of addresses at the per-address
794/// rate limit could keep `pending_total` permanently full with entries no
795/// real account ever asked for, since before this a stranger could not
796/// occupy a slot without naming one.
797///
798/// The fix an account-keyed bound cannot have is not to bound unresolved
799/// hints at all — there is no account here for a bound to be *of*. A fresh
800/// `request_uri` is minted through [`ParStore::mint`] and handed back with
801/// the same `expires_in` a real push gets, and nothing is stored under it
802/// anywhere: not in [`ParStore`], so `GET /oauth/authorize` answers
803/// [`crate::oauth::authorize::AuthorizeError::UnknownRequestUri`] for it,
804/// as it does for a real one this server has swept; not in
805/// [`DecisionStore`], so
806/// `bot.did.getAuthorization` answers not-found for the same reason it
807/// always would — no credential can authenticate as a string that resolved
808/// to no account.
809///
810/// That leaves one thing this must still answer identically to a real
811/// push: [`DecisionStore::check_room`], the *server's* own bound, which is
812/// a fact about this deployment's load and not about `login_hint` at all —
813/// true for every caller alike, so answering it truthfully leaks nothing.
814/// Skipping this check would make a full store itself an oracle (real
815/// accounts start answering `403`, unresolved hints never would), so the
816/// close reads it — without writing anything, whichever way it answers.
817fn push_for_unresolved_hint(
818    store: &dyn ParStore,
819    decisions: &dyn DecisionStore,
820) -> Result<(String, Duration), ParError> {
821    UNRESOLVED_HINT_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
822    decisions.check_room().map_err(|refused| {
823        SERVER_FULL_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
824        ParError::TooManyPending(refused)
825    })?;
826    Ok((store.mint(), REQUEST_URI_TTL))
827}
828
829/// What a ceiling's row names where a gate's row names the policy version
830/// it judged under.
831///
832/// A ceiling is this deployment's own configuration, not one of the
833/// operator's numbered policies — see [`CEILING_RULE`] — so there is no tree
834/// version to put here, and putting the tree's would say a policy tree
835/// refused something no policy tree ever saw.
836const CEILING_VERSION: &str = "ceiling";
837
838/// The verdict for one request: what the ceiling narrows or denies. The
839/// gate is not asked here — [`push_authorization_request`] already asked it,
840/// once, before this account was even known to exist, because
841/// `didbot_policy::Subject::Grant` has no account for this function's `did`
842/// to change the answer of. A gate's own refusal is
843/// [`ParError::ClientRefused`], not a [`Verdict`] this function could
844/// return; only the ceiling's per-account narrowing lives here.
845///
846/// `didbot_pds::policy_tree::TreePolicyGate::judge` records its own refusal
847/// as it makes it, naming the policies that fired and the version they were
848/// judged under; nothing here writes that down a second time. What only this
849/// function knows is what the *ceiling* did, which no gate saw, and that is
850/// the one row it writes.
851///
852/// `subject` is [`push_authorization_request`]'s own — built once there for
853/// the gate it already asked, and borrowed here rather than rebuilt, since
854/// the two calls describe the same request.
855fn decide(
856    did: &str,
857    client_id: &str,
858    scope: &ScopeSet,
859    subject: &didbot_pds::policy::Subject<'_>,
860    seams: &DecisionSeams<'_>,
861) -> Verdict {
862    let ceiling = seams.scope_policy.ceiling(did, client_id);
863    let verdict = verdict_for(scope, &ceiling, CEILING_RULE);
864
865    // `plan/scope-policy.md`'s "log every scopedown": the record itself is
866    // one account of it, and this is the operator's. Payload-free, like
867    // every other row in that log — the reason names what was cut, never
868    // what the client sent, so an atom the grammar could not read, which is
869    // the client's own text, is not among the cut atoms. A narrowing is not
870    // a refusal and does not claim to be one. Written before the gate is
871    // asked, because what the ceiling did is a fact about this request
872    // whatever the gate goes on to say.
873    let ceiling_row = match &verdict {
874        Verdict::Allow => None,
875        Verdict::Narrow { cut, rule, .. } => Some((
876            didbot_pds::evaluation_log::Verdict::Narrow,
877            rule.clone(),
878            format!("scope narrowed by `{rule}`"),
879            cut.0
880                .iter()
881                .filter(|atom| !matches!(atom, Scope::Unknown(_)))
882                .map(ToString::to_string)
883                .collect::<Vec<String>>(),
884        )),
885        Verdict::Deny { rule, .. } => Some((
886            didbot_pds::evaluation_log::Verdict::Reject,
887            rule.clone(),
888            format!("grant refused by `{rule}`"),
889            Vec::<String>::new(),
890        )),
891    };
892    if let Some((log_verdict, rule, reason, cut)) = ceiling_row {
893        seams
894            .evaluation_log
895            .record_denial(didbot_pds::evaluation_log::Denial {
896                account: did,
897                policies_fired: &[rule],
898                verdict: log_verdict,
899                reason: &reason,
900                cut: &cut,
901                payload_hash: &seams.evaluation_log.hash_subject(subject),
902                policy_version: &didbot_pds::policy::PolicyVersion(CEILING_VERSION.to_owned()),
903                now: subject.universal().now,
904                subject,
905            });
906    }
907
908    verdict
909}
910
911/// The scheme, host and port of a `client_id`.
912///
913/// `plan/app-allowlist.md`: the origin is the only part of a `client_id`
914/// that is a fact about the world, since serving a metadata document there
915/// means controlling that host. Falls back to the whole string when it will
916/// not parse as a URL, which `client_metadata::resolve_client` has already
917/// ruled out by the time this is called.
918fn origin_of(client_id: &str) -> String {
919    url::Url::parse(client_id)
920        .map(|url| url.origin().ascii_serialization())
921        .unwrap_or_else(|_| client_id.to_owned())
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927    use crate::oauth::authorize::GrantAnyScope;
928    use crate::oauth::client_metadata::{ClientMetadataCache, MemoryClientMetadataCache};
929    use crate::oauth::consent::MemoryConsentStore;
930    use crate::oauth::decision::MemoryDecisionStore;
931
932    const AGENT: &str = "did:web:agent.example";
933
934    fn document() -> ClientMetadataDocument {
935        ClientMetadataDocument {
936            client_id: "https://client.example/id.json".to_owned(),
937            application_type: crate::oauth::client_metadata::ApplicationType::Web,
938            client_name: Some("Krillbridge".to_owned()),
939            client_uri: None,
940            logo_uri: None,
941            redirect_uris: vec!["https://client.example/cb".to_owned()],
942            grant_types: vec!["authorization_code".to_owned()],
943            response_types: vec!["code".to_owned()],
944            authentication: crate::oauth::client_auth::ClientAuthentication::public(),
945            scope: Some("atproto".to_owned()),
946            dpop_bound_access_tokens: true,
947        }
948    }
949
950    fn cache_with(document: ClientMetadataDocument) -> MemoryClientMetadataCache {
951        let cache = MemoryClientMetadataCache::new();
952        let key = ClientKey::compute(&document);
953        cache.put(
954            &document.client_id.clone(),
955            crate::oauth::client_metadata::CachedClientMetadata {
956                document,
957                key,
958                fetched_at: OffsetDateTime::now_utc(),
959            },
960        );
961        cache
962    }
963
964    fn request() -> ParRequest {
965        ParRequest {
966            client_id: "https://client.example/id.json".to_owned(),
967            response_type: Some("code".to_owned()),
968            response_mode: None,
969            redirect_uri: Some("https://client.example/cb".to_owned()),
970            scope: Some("atproto".to_owned()),
971            state: Some("xyz".to_owned()),
972            code_challenge: Some(CHALLENGE.to_owned()),
973            code_challenge_method: Some("S256".to_owned()),
974            login_hint: Some(AGENT.to_owned()),
975            credentials: ClientCredentials::default(),
976            dpop_jkt: None,
977            proof_key: DpopThumbprint("the-pushs-own-key".to_owned()),
978        }
979    }
980
981    /// The S256 challenge RFC 7636's appendix B works through.
982    const CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
983
984    /// Everything a push needs beyond the request, owned by the test so the
985    /// borrowed [`DecisionSeams`] can point into it.
986    struct Fixture {
987        consent: MemoryConsentStore,
988        decisions: MemoryDecisionStore,
989        assertions: ClientAssertionVerifier,
990        permission_sets: PermissionSets,
991        gate: Box<dyn didbot_pds::policy::PolicyGate>,
992        sink: std::sync::Arc<didbot_pds::evaluation_log::MemorySink>,
993        log: std::sync::Arc<didbot_pds::evaluation_log::EvaluationLog>,
994    }
995
996    impl Fixture {
997        /// A deployment with nothing loaded: the empty policy set
998        /// `plan/policy.md` says permits everything.
999        fn new() -> Self {
1000            let sink = std::sync::Arc::new(didbot_pds::evaluation_log::MemorySink::new());
1001            Self {
1002                consent: MemoryConsentStore::new(),
1003                decisions: MemoryDecisionStore::new(),
1004                assertions: ClientAssertionVerifier::new(),
1005                permission_sets: PermissionSets::default(),
1006                gate: Box::new(didbot_pds::policy::NoPolicyGate),
1007                log: std::sync::Arc::new(didbot_pds::evaluation_log::EvaluationLog::new(
1008                    sink.clone(),
1009                )),
1010                sink,
1011            }
1012        }
1013
1014        /// The same, with one gate loaded.
1015        fn with_gate(gate: Box<dyn didbot_pds::policy::PolicyGate>) -> Self {
1016            Self {
1017                gate,
1018                ..Self::new()
1019            }
1020        }
1021
1022        /// A deployment whose startup source supplies one real policy,
1023        /// denying every grant from a client whose id matches `pattern`.
1024        /// This is what `plan/app-allowlist.md`'s admission refusal *is*
1025        /// now: a denial in the tree, judged at PAR like any other.
1026        fn denying_client(pattern: &str) -> Self {
1027            let fixture = Self::new();
1028            let mut records = std::collections::HashMap::new();
1029            records.insert("startup[0]".to_owned(), tree_denying_client(pattern));
1030            let (tree, report) = didbot_pds::policy_tree::build_tree(
1031                Vec::new(),
1032                &records,
1033                &std::collections::HashMap::new(),
1034                &Default::default(),
1035            );
1036            assert!(report.scoped_denials.is_empty(), "the policy compiled");
1037            let gate = didbot_pds::policy_tree::TreePolicyGate::new(tree)
1038                .with_evaluation_log(std::sync::Arc::clone(&fixture.log));
1039            Self {
1040                gate: Box::new(gate),
1041                ..fixture
1042            }
1043        }
1044
1045        /// Every row this fixture's log has written. A window stays open
1046        /// until something closes it, so this closes them all first — see
1047        /// `EvaluationLog::flush_stale`.
1048        fn rows(&self) -> Vec<didbot_pds::evaluation_log::DenialRow> {
1049            self.log
1050                .flush_stale(OffsetDateTime::now_utc() + Duration::hours(1));
1051            self.sink.rows()
1052        }
1053
1054        fn seams<'a>(&'a self, ceiling: &'a dyn ScopePolicy) -> DecisionSeams<'a> {
1055            DecisionSeams {
1056                scope_policy: ceiling,
1057                consent_store: &self.consent,
1058                decisions: &self.decisions,
1059                gate: self.gate.as_ref(),
1060                evaluation_log: self.log.as_ref(),
1061                pds_did: "did:web:pds.example",
1062                pds_hostname: "pds.example",
1063                loopback_clients: crate::oauth::client_metadata::LoopbackClients::for_zone(
1064                    "pds.example",
1065                ),
1066                client_assertions: &self.assertions,
1067                issuer: "https://pds.example",
1068                permission_sets: &self.permission_sets,
1069            }
1070        }
1071    }
1072
1073    /// The account every hint in this module resolves to, standing in for
1074    /// the HTTP layer's own registry lookup.
1075    fn resolves(hint: &str) -> Option<String> {
1076        (hint == AGENT).then(|| AGENT.to_owned())
1077    }
1078
1079    async fn push(
1080        fixture: &Fixture,
1081        store: &dyn ParStore,
1082        ceiling: &dyn ScopePolicy,
1083        request: ParRequest,
1084    ) -> Result<(String, Duration), ParError> {
1085        push_authorization_request(
1086            &didbot_http::client(),
1087            &cache_with(document()),
1088            store,
1089            request,
1090            &resolves,
1091            &fixture.seams(ceiling),
1092        )
1093        .await
1094    }
1095
1096    /// `plan/app-allowlist.md`'s exit criterion, met by a policy rather than
1097    /// by a seam of its own: a client the tree denies outright gets refused
1098    /// at PAR itself, before a `request_uri` or a decision record exists —
1099    /// the same shape an unresolvable `login_hint` leaves behind, and for
1100    /// the same reason: neither is a fact this unauthenticated endpoint may
1101    /// hand back distinguishably. See [`ParError::ClientRefused`].
1102    #[tokio::test]
1103    async fn a_denied_client_is_refused_at_par() {
1104        let fixture = Fixture::denying_client("^https://client\\.example/.*$");
1105        let store = MemoryParStore::new();
1106        let err = push(&fixture, &store, &GrantAnyScope, request())
1107            .await
1108            .unwrap_err();
1109        assert_eq!(err, ParError::ClientRefused);
1110        let (pending, _) = fixture.decisions.pending_for(AGENT, None);
1111        assert!(
1112            pending.is_empty(),
1113            "a client refused outright gets no decision record"
1114        );
1115    }
1116
1117    /// **The outbound request this closes.** `POST /oauth/par` is
1118    /// unauthenticated and `client_id` is a URL the caller chose, so a fetch
1119    /// before the gate is asked lets anybody make this server reach a host
1120    /// of their choosing — even one the operator has denied outright. The
1121    /// gate judges `client_id` and the requested scope, both of which are in
1122    /// the request, so it is asked first and a refused client costs no
1123    /// outbound request at all.
1124    ///
1125    /// Measured on the clock: the `client_id` names a host that accepts and
1126    /// never answers, so a fetch would cost `FETCH_TIMEOUT`.
1127    #[tokio::test(start_paused = true)]
1128    async fn a_denied_client_is_refused_before_its_metadata_is_fetched() {
1129        let addr = didbot_http::test_support::hung_server();
1130        let fixture = Fixture::denying_client("^http://.*$");
1131        let store = MemoryParStore::new();
1132        let mut hung = request();
1133        hung.client_id = format!("http://{addr}/id.json");
1134
1135        let started = tokio::time::Instant::now();
1136        let err = push_authorization_request(
1137            &crate::oauth::client_metadata_http_client(),
1138            &MemoryClientMetadataCache::new(),
1139            &store,
1140            hung,
1141            &resolves,
1142            &fixture.seams(&GrantAnyScope),
1143        )
1144        .await
1145        .unwrap_err();
1146        assert_eq!(err, ParError::ClientRefused);
1147        assert_eq!(
1148            started.elapsed(),
1149            std::time::Duration::ZERO,
1150            "the document was fetched before the gate was asked"
1151        );
1152    }
1153
1154    /// **The oracle this closes.** `client_id` names a host the caller
1155    /// chose, so what the fetch found is an answer about this deployment's
1156    /// own network. Two pushes whose fetches fail in different ways — one
1157    /// aimed at an address this server refuses outright, one at a host that
1158    /// answers with a redirect — read identically to the caller, and the
1159    /// refusal names neither the address nor the redirect target.
1160    #[tokio::test]
1161    async fn every_way_a_client_metadata_fetch_can_fail_reads_the_same() {
1162        let fixture = Fixture::new();
1163        let store = MemoryParStore::new();
1164        let cache = MemoryClientMetadataCache::new();
1165        // A host that answers with a redirect, reached the one way a fetch
1166        // to loopback is allowed at all — a `.localhost` deployment.
1167        let host = crate::oauth::client_metadata::tests_client_host().await;
1168        let http =
1169            crate::oauth::client_metadata::tests_reaching(host, reqwest::redirect::Policy::none());
1170
1171        // An address this server refuses outright, on an ordinary
1172        // deployment.
1173        let mut refused_address = request();
1174        refused_address.client_id = "https://169.254.169.254/id.json".to_owned();
1175        let mut redirecting = request();
1176        redirecting.client_id = format!("{}/moved.json", crate::oauth::client_metadata::TESTS_HOST);
1177
1178        let mut answers = Vec::new();
1179        for (probe, zone) in [(refused_address, "pds.example"), (redirecting, "localhost")] {
1180            let seams = DecisionSeams {
1181                loopback_clients: crate::oauth::client_metadata::LoopbackClients::for_zone(zone),
1182                ..fixture.seams(&GrantAnyScope)
1183            };
1184            let err = push_authorization_request(&http, &cache, &store, probe, &resolves, &seams)
1185                .await
1186                .expect_err("neither client_id yields a usable document");
1187            assert!(matches!(err, ParError::ClientMetadata(_)), "{err:?}");
1188            answers.push(err.to_string());
1189        }
1190        assert_eq!(answers[0], answers[1]);
1191        assert!(
1192            !answers[0].contains("169.254") && !answers[0].contains("moved"),
1193            "the refusal names what the fetch found: {}",
1194            answers[0]
1195        );
1196    }
1197
1198    /// A native CLI signs in with the atproto profile's virtual
1199    /// `http://localhost` `client_id`, whose document is built from its own
1200    /// query. That fetches nothing, so a production deployment takes it.
1201    /// What it still refuses is a `client_id` that would make it fetch from
1202    /// loopback: the host accepts and never answers, so a fetch would cost
1203    /// `FETCH_TIMEOUT`.
1204    #[tokio::test(start_paused = true)]
1205    async fn a_production_zone_takes_a_virtual_loopback_client_but_fetches_no_loopback_one() {
1206        let fixture = Fixture::new();
1207        assert_eq!(
1208            fixture.seams(&GrantAnyScope).loopback_clients,
1209            crate::oauth::client_metadata::LoopbackClients::Refused,
1210            "the fixture is a production zone"
1211        );
1212        let store = MemoryParStore::new();
1213        let cache = MemoryClientMetadataCache::new();
1214
1215        let mut native = request();
1216        native.client_id =
1217            "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A4567%2Fcallback&scope=atproto"
1218                .to_owned();
1219        native.redirect_uri = Some("http://127.0.0.1:4567/callback".to_owned());
1220        let seams = fixture.seams(&GrantAnyScope);
1221        push_authorization_request(
1222            &didbot_http::client(),
1223            &cache,
1224            &store,
1225            native,
1226            &resolves,
1227            &seams,
1228        )
1229        .await
1230        .expect("a virtual loopback client is accepted on a production zone");
1231
1232        let addr = didbot_http::test_support::hung_server();
1233        let mut fetched = request();
1234        fetched.client_id = format!("http://127.0.0.1:{}/client-metadata.json", addr.port());
1235        let started = tokio::time::Instant::now();
1236        let err = push_authorization_request(
1237            &crate::oauth::client_metadata_http_client(),
1238            &cache,
1239            &store,
1240            fetched,
1241            &resolves,
1242            &seams,
1243        )
1244        .await
1245        .expect_err("a production zone does not fetch from loopback");
1246        assert!(matches!(err, ParError::ClientMetadata(_)), "{err:?}");
1247        assert_eq!(started.elapsed(), std::time::Duration::ZERO);
1248    }
1249
1250    #[tokio::test]
1251    async fn an_unregistered_redirect_uri_is_refused_even_when_admitted() {
1252        let fixture = Fixture::new();
1253        let store = MemoryParStore::new();
1254        let mut bad = request();
1255        bad.redirect_uri = Some("https://client.example/somewhere-else".to_owned());
1256        let err = push(&fixture, &store, &GrantAnyScope, bad)
1257            .await
1258            .unwrap_err();
1259        assert!(matches!(err, ParError::RedirectUriNotRegistered(_)));
1260    }
1261
1262    /// A pushed request nobody takes is swept by the next push once it has
1263    /// expired, so an abandoned sign-in does not stay until restart.
1264    #[tokio::test]
1265    async fn an_untaken_request_is_swept_once_it_has_expired() {
1266        let fixture = Fixture::new();
1267        let store = MemoryParStore::new();
1268        let (abandoned, _) = push(&fixture, &store, &GrantAnyScope, request())
1269            .await
1270            .unwrap();
1271        store
1272            .entries
1273            .write()
1274            .unwrap()
1275            .get_mut(&abandoned)
1276            .unwrap()
1277            .expires_at = OffsetDateTime::now_utc() - time::Duration::seconds(1);
1278
1279        let (fresh, _) = push(&fixture, &store, &GrantAnyScope, request())
1280            .await
1281            .unwrap();
1282        assert_eq!(store.len(), 1);
1283        assert!(store.take(&fresh).is_some());
1284        assert_eq!(store.len(), 0);
1285    }
1286
1287    #[tokio::test]
1288    async fn a_valid_admitted_push_mints_a_one_shot_request_uri_and_a_decision() {
1289        let fixture = Fixture::new();
1290        let store = MemoryParStore::new();
1291        let (uri, ttl) = push(&fixture, &store, &GrantAnyScope, request())
1292            .await
1293            .unwrap();
1294        assert!(uri.starts_with(REQUEST_URI_PREFIX));
1295        assert_eq!(ttl, REQUEST_URI_TTL);
1296
1297        let record = fixture.decisions.get(&uri).expect("a decision was minted");
1298        assert_eq!(record.account, AGENT);
1299        assert_eq!(record.client_origin, "https://client.example");
1300        assert_eq!(record.verdict, Verdict::Allow);
1301        assert!(record.first_time);
1302        assert!(record.token.is_some());
1303        // The client's own copy is kept, and is not on the wire.
1304        assert_eq!(record.client_self.name.as_deref(), Some("Krillbridge"));
1305        assert!(!record.to_json().to_string().contains("Krillbridge"));
1306
1307        let pushed = store.take(&uri).expect("request is there once");
1308        assert_eq!(pushed.redirect_uri, "https://client.example/cb");
1309        assert_eq!(pushed.did, AGENT);
1310        // One-shot: gone the second time.
1311        assert!(store.take(&uri).is_none());
1312    }
1313
1314    #[tokio::test]
1315    async fn plain_code_challenge_method_is_refused() {
1316        let fixture = Fixture::new();
1317        let store = MemoryParStore::new();
1318        let mut bad = request();
1319        bad.code_challenge_method = Some("plain".to_owned());
1320        let err = push(&fixture, &store, &GrantAnyScope, bad)
1321            .await
1322            .unwrap_err();
1323        assert!(matches!(err, ParError::UnsupportedCodeChallengeMethod(_)));
1324    }
1325
1326    /// Every S256 challenge is 43 base64url characters, whatever verifier it
1327    /// digests, so a challenge of any other shape can never be met.
1328    #[tokio::test]
1329    async fn a_challenge_that_is_not_s256_shaped_is_refused() {
1330        let fixture = Fixture::new();
1331        let store = MemoryParStore::new();
1332        for challenge in [
1333            "challenge".to_owned(),
1334            format!("{CHALLENGE}A"),
1335            CHALLENGE.replace('-', "+"),
1336            "a".repeat(2 * 1024 * 1024),
1337        ] {
1338            let mut bad = request();
1339            bad.code_challenge = Some(challenge);
1340            assert_eq!(
1341                push(&fixture, &store, &GrantAnyScope, bad)
1342                    .await
1343                    .unwrap_err(),
1344                ParError::MalformedCodeChallenge
1345            );
1346        }
1347        assert!(push(&fixture, &store, &GrantAnyScope, request())
1348            .await
1349            .is_ok());
1350    }
1351
1352    /// `state` and `login_hint` are kept with the pushed request, so both
1353    /// are bounded before anything else is spent on it. A value at the
1354    /// bound still passes.
1355    #[tokio::test]
1356    async fn an_oversized_state_or_login_hint_is_refused() {
1357        let fixture = Fixture::new();
1358        let store = MemoryParStore::new();
1359
1360        let mut longest = request();
1361        longest.state = Some("s".repeat(MAX_STATE_BYTES));
1362        assert!(push(&fixture, &store, &GrantAnyScope, longest)
1363            .await
1364            .is_ok());
1365
1366        let mut bad = request();
1367        bad.state = Some("s".repeat(MAX_STATE_BYTES + 1));
1368        assert_eq!(
1369            push(&fixture, &store, &GrantAnyScope, bad)
1370                .await
1371                .unwrap_err(),
1372            ParError::TooLong {
1373                field: "state",
1374                max: MAX_STATE_BYTES
1375            }
1376        );
1377
1378        let mut bad = request();
1379        bad.login_hint = Some(format!("{AGENT}:{}", "9".repeat(MAX_LOGIN_HINT_BYTES)));
1380        assert_eq!(
1381            push(&fixture, &store, &GrantAnyScope, bad)
1382                .await
1383                .unwrap_err(),
1384            ParError::TooLong {
1385                field: "login_hint",
1386                max: MAX_LOGIN_HINT_BYTES
1387            }
1388        );
1389    }
1390
1391    /// A request naming no `login_hint` at all is malformed the same way a
1392    /// request naming no `scope` is — every account alike would refuse it,
1393    /// so saying so is not an enumeration hole.
1394    #[tokio::test]
1395    async fn a_missing_login_hint_is_a_missing_parameter() {
1396        let fixture = Fixture::new();
1397        let store = MemoryParStore::new();
1398        let mut missing = request();
1399        missing.login_hint = None;
1400        assert_eq!(
1401            push(&fixture, &store, &GrantAnyScope, missing)
1402                .await
1403                .unwrap_err(),
1404            ParError::MissingParameter("login_hint")
1405        );
1406    }
1407
1408    /// A `login_hint` naming nobody gets the same `201` a real account
1409    /// gets — see [`push_for_unresolved_hint`] — and nothing is stored
1410    /// anywhere under it: not in [`ParStore`], so there is nothing for
1411    /// `authorize` to redeem, and not in [`DecisionStore`] either, so no
1412    /// bound anywhere — this hint's own or the server's total — is any
1413    /// different after this push than before it.
1414    #[tokio::test]
1415    async fn an_unresolvable_hint_is_accepted_and_stores_nothing_at_all() {
1416        let fixture = Fixture::new();
1417        let store = MemoryParStore::new();
1418        let mut bad = request();
1419        bad.login_hint = Some("did:web:nobody.example".to_owned());
1420        let (uri, ttl) = push(&fixture, &store, &GrantAnyScope, bad)
1421            .await
1422            .expect("an unresolvable hint is still a well-formed push");
1423        assert_eq!(ttl, REQUEST_URI_TTL);
1424        assert!(uri.starts_with(REQUEST_URI_PREFIX));
1425
1426        // Nothing for `authorize` to take, and no record for anything else
1427        // to read back.
1428        assert!(store.take(&uri).is_none());
1429        assert!(fixture.decisions.get(&uri).is_none());
1430    }
1431
1432    /// Round 2's fix bounded a nonexistent hint the same way a real
1433    /// account is bounded, keyed on the raw `login_hint` string. That
1434    /// reopened the hole: a real account's bucket is one DID however it is
1435    /// spelled (`resolve_identifier` folds a handle and its DID together),
1436    /// while every distinct spelling of a nonexistent one is its own
1437    /// bucket. Pushing many invented hints must never accumulate anywhere,
1438    /// so the server's total bound — the one shared, identity-independent
1439    /// resource — cannot be filled by them either.
1440    #[tokio::test]
1441    async fn many_invented_hints_leave_the_store_empty() {
1442        let fixture = Fixture {
1443            decisions: MemoryDecisionStore::with_bounds(1, 512),
1444            ..Fixture::new()
1445        };
1446        let store = MemoryParStore::new();
1447        for n in 0..512 {
1448            let mut bad = request();
1449            bad.login_hint = Some(format!("did:web:nobody-{n}.example"));
1450            push(&fixture, &store, &GrantAnyScope, bad)
1451                .await
1452                .expect("an unresolvable hint is always well-formed");
1453        }
1454        // If any of the above had cost a real slot, this would answer
1455        // `TooMany` instead of admitting the one real account asking.
1456        let (uri, _) = push(&fixture, &store, &GrantAnyScope, request())
1457            .await
1458            .expect("512 invented hints left the store's total bound untouched");
1459        assert!(fixture.decisions.get(&uri).is_some());
1460    }
1461
1462    /// The one `403` this endpoint answers with is server load, not
1463    /// account state: a full store refuses a real account and an
1464    /// unresolvable hint with the identical body.
1465    #[tokio::test]
1466    async fn a_full_store_refuses_a_real_account_and_an_unresolvable_hint_alike() {
1467        let fixture = Fixture {
1468            decisions: MemoryDecisionStore::with_bounds(10, 1),
1469            ..Fixture::new()
1470        };
1471        let store = MemoryParStore::new();
1472        push(&fixture, &store, &GrantAnyScope, request())
1473            .await
1474            .expect("the one total slot admits the first push");
1475
1476        let from_real = push(&fixture, &store, &GrantAnyScope, request())
1477            .await
1478            .unwrap_err();
1479        let mut bad = request();
1480        bad.login_hint = Some("did:web:nobody.example".to_owned());
1481        let from_unresolved = push(&fixture, &store, &GrantAnyScope, bad)
1482            .await
1483            .unwrap_err();
1484
1485        assert!(matches!(from_real, ParError::TooManyPending(_)));
1486        assert_eq!(from_real, from_unresolved);
1487    }
1488
1489    struct EmptyCeiling;
1490    impl ScopePolicy for EmptyCeiling {
1491        fn ceiling(&self, _did: &str, _client_id: &str) -> ScopeSet {
1492            ScopeSet::default()
1493        }
1494    }
1495
1496    /// A refused request still gets a record — the agent is told it was
1497    /// asked and told the answer — and gets no reference, so there is
1498    /// nothing to approve.
1499    #[tokio::test]
1500    async fn a_request_outside_the_ceiling_is_recorded_as_a_denial_with_no_token() {
1501        let fixture = Fixture::new();
1502        let store = MemoryParStore::new();
1503        let (uri, _) = push(&fixture, &store, &EmptyCeiling, request())
1504            .await
1505            .unwrap();
1506        let record = fixture.decisions.get(&uri).expect("a decision was minted");
1507        assert!(record.verdict.is_deny());
1508        assert!(record.token.is_none());
1509    }
1510
1511    struct BaseScopeOnly;
1512    impl ScopePolicy for BaseScopeOnly {
1513        fn ceiling(&self, _did: &str, _client_id: &str) -> ScopeSet {
1514            ScopeSet::parse("atproto").unwrap()
1515        }
1516    }
1517
1518    #[tokio::test]
1519    async fn a_request_the_ceiling_shrinks_is_recorded_as_a_narrowing() {
1520        let fixture = Fixture::new();
1521        let store = MemoryParStore::new();
1522        let mut wide = request();
1523        wide.scope = Some("atproto repo:app.bsky.feed.post".to_owned());
1524        let (uri, _) = push(&fixture, &store, &BaseScopeOnly, wide).await.unwrap();
1525        let record = fixture.decisions.get(&uri).expect("a decision was minted");
1526        let Verdict::Narrow { granted, cut, .. } = &record.verdict else {
1527            panic!("expected a narrowing, got {:?}", record.verdict);
1528        };
1529        assert_eq!(granted.to_string(), "atproto");
1530        assert_eq!(cut.to_string(), "repo:app.bsky.feed.post");
1531        assert!(record.token.is_some());
1532    }
1533
1534    /// **What the agent approves is what the login is issued at.** The
1535    /// record shows `granted` and names `cut`; the reference the agent
1536    /// answers with carries `granted` alone, so a ceiling loosened after the
1537    /// approval cannot turn a cut atom into one the login holds.
1538    #[tokio::test]
1539    async fn a_narrowed_push_is_approvable_only_at_the_granted_scope() {
1540        let fixture = Fixture::new();
1541        let store = MemoryParStore::new();
1542        let mut wide = request();
1543        wide.scope = Some("atproto repo:app.bsky.feed.post".to_owned());
1544        let (uri, _) = push(&fixture, &store, &BaseScopeOnly, wide).await.unwrap();
1545        let record = fixture.decisions.get(&uri).expect("a decision was minted");
1546        let reference = record.token.clone().expect("a narrowing is approvable");
1547
1548        let pending = fixture
1549            .consent
1550            .consume(&reference)
1551            .expect("the reference is live");
1552        assert_eq!(pending.granted_scope.to_string(), "atproto");
1553        assert_eq!(
1554            record.requested.to_string(),
1555            "atproto repo:app.bsky.feed.post"
1556        );
1557    }
1558
1559    /// `plan/scope-policy.md`'s "log every scopedown", and the shape the
1560    /// row takes: a narrowing says it narrowed, names the ceiling rule that
1561    /// did it, is attributed to the ceiling rather than to a policy version
1562    /// no policy tree ever produced, and carries what it cut as atoms an
1563    /// operator can count rather than prose they would have to parse.
1564    #[tokio::test]
1565    async fn a_narrowing_is_logged_as_a_narrowing_by_the_ceiling() {
1566        let fixture = Fixture::new();
1567        let store = MemoryParStore::new();
1568        let mut wide = request();
1569        wide.scope = Some("atproto repo:app.bsky.feed.post".to_owned());
1570        push(&fixture, &store, &BaseScopeOnly, wide).await.unwrap();
1571
1572        let rows = fixture.rows();
1573        assert_eq!(rows.len(), 1, "one narrowing, one row: {rows:?}");
1574        assert_eq!(
1575            rows[0].verdict,
1576            didbot_pds::evaluation_log::Verdict::Narrow,
1577            "a narrowing must not claim the request was refused"
1578        );
1579        assert_eq!(rows[0].account, AGENT);
1580        assert_eq!(rows[0].policies_fired, vec![CEILING_RULE.to_owned()]);
1581        assert_eq!(rows[0].policy_version, CEILING_VERSION);
1582        assert_eq!(rows[0].cut, vec!["repo:app.bsky.feed.post".to_owned()]);
1583        assert_eq!(
1584            rows[0].reason,
1585            format!("scope narrowed by `{CEILING_RULE}`")
1586        );
1587    }
1588
1589    /// Two narrowings of one account by one rule that cut different atoms
1590    /// are two rows: the cut is part of what makes a denial the same denial,
1591    /// so collapsing them would lose one of the two answers.
1592    #[tokio::test]
1593    async fn two_narrowings_that_cut_different_atoms_are_two_rows() {
1594        let fixture = Fixture::new();
1595        let store = MemoryParStore::new();
1596        for scope in [
1597            "atproto repo:app.bsky.feed.post",
1598            "atproto repo:app.bsky.actor.profile",
1599        ] {
1600            let mut wide = request();
1601            wide.scope = Some(scope.to_owned());
1602            push(&fixture, &store, &BaseScopeOnly, wide).await.unwrap();
1603        }
1604
1605        let mut cuts: Vec<Vec<String>> = fixture.rows().into_iter().map(|row| row.cut).collect();
1606        cuts.sort();
1607        assert_eq!(
1608            cuts,
1609            vec![
1610                vec!["repo:app.bsky.actor.profile".to_owned()],
1611                vec!["repo:app.bsky.feed.post".to_owned()],
1612            ]
1613        );
1614    }
1615
1616    /// One policy, supplied the way a deployment's startup source supplies
1617    /// one, denying every grant from `client_id_pattern`.
1618    fn tree_denying_client(client_id_pattern: &str) -> didbot_policy_records::ParsedPolicy {
1619        let applicability = didbot_policy_records::Applicability::Grant {
1620            client_ids: didbot_policy_records::Scope::Any,
1621        };
1622        didbot_policy_records::ParsedPolicy {
1623            observes: applicability.clone(),
1624            applicability,
1625            staleness: didbot_policy_records::Staleness::Linearized,
1626            language: "regex".to_owned(),
1627            predicate: serde_json::json!({
1628                "kind": "denyClient",
1629                "clientId": client_id_pattern,
1630                "reason": "this client is not admitted here",
1631            }),
1632            revision: didbot_policy_records::Revision(1),
1633            lookups: Vec::new(),
1634        }
1635    }
1636
1637    /// A policy's refusal is written down once, by the gate that made it —
1638    /// `TreePolicyGate::judge` records as it judges — and PAR writes no
1639    /// second row saying the same thing in different words. `push` asks the
1640    /// gate exactly once regardless of what it answers (see
1641    /// `push_authorization_request`'s own doc), so this holds whether or not
1642    /// a decision record ever exists to write a second row about.
1643    #[tokio::test]
1644    async fn a_policy_denial_is_logged_once_by_the_gate() {
1645        let fixture = Fixture::denying_client("^https://client\\.example/.*$");
1646        let store = MemoryParStore::new();
1647        assert_eq!(
1648            push(&fixture, &store, &GrantAnyScope, request())
1649                .await
1650                .unwrap_err(),
1651            ParError::ClientRefused
1652        );
1653
1654        let rows = fixture.rows();
1655        assert_eq!(rows.len(), 1, "one refusal, one row: {rows:?}");
1656        assert_eq!(rows[0].verdict, didbot_pds::evaluation_log::Verdict::Reject);
1657        assert_eq!(
1658            rows[0].policies_fired,
1659            vec!["0".to_owned()],
1660            "the gate names the policy that fired"
1661        );
1662    }
1663
1664    /// A gate that refuses the grant. `plan/policy.md`: policy denies, and
1665    /// its answer beats whatever the ceiling would have reached — so
1666    /// thoroughly that the ceiling is never consulted at all.
1667    struct RefuseEveryGrant;
1668    impl didbot_pds::policy::PolicyGate for RefuseEveryGrant {
1669        fn judge(&self, _subject: &didbot_pds::policy::Subject<'_>) -> didbot_pds::policy::Outcome {
1670            didbot_pds::policy::Outcome::Reject {
1671                reason: "this client is not admitted".to_owned(),
1672            }
1673        }
1674        fn version(&self) -> didbot_pds::policy::PolicyVersion {
1675            didbot_pds::policy::PolicyVersion("test-1".to_owned())
1676        }
1677    }
1678
1679    #[tokio::test]
1680    async fn a_policy_refusal_beats_a_ceiling_that_would_have_allowed() {
1681        let fixture = Fixture::with_gate(Box::new(RefuseEveryGrant));
1682        let store = MemoryParStore::new();
1683        assert_eq!(
1684            push(&fixture, &store, &GrantAnyScope, request())
1685                .await
1686                .unwrap_err(),
1687            ParError::ClientRefused,
1688            "the gate's own reason and rule never reach the wire"
1689        );
1690    }
1691
1692    /// A push from the profile's local client, `client_id` and the
1693    /// `redirect_uri` its listener is on, to a deployment that builds its
1694    /// document.
1695    async fn push_local(
1696        fixture: &Fixture,
1697        store: &dyn ParStore,
1698        client_id: &str,
1699        redirect_uri: &str,
1700    ) -> Result<(String, Duration), ParError> {
1701        let mut local = request();
1702        local.client_id = client_id.to_owned();
1703        local.redirect_uri = Some(redirect_uri.to_owned());
1704        let mut seams = fixture.seams(&GrantAnyScope);
1705        seams.loopback_clients = crate::oauth::client_metadata::LoopbackClients::Fetched;
1706        push_authorization_request(
1707            &didbot_http::client(),
1708            &MemoryClientMetadataCache::new(),
1709            store,
1710            local,
1711            &resolves,
1712            &seams,
1713        )
1714        .await
1715    }
1716
1717    /// **The impersonation this closes.** Any process on the client's
1718    /// machine can send a local client's `client_id` and get the same
1719    /// document, so a copy used to read "seen before" once the original had
1720    /// been approved. A client at the local origin is never seen before.
1721    #[tokio::test]
1722    async fn a_second_process_sending_a_local_clients_id_is_never_seen_before() {
1723        let fixture = Fixture::new();
1724        let store = MemoryParStore::new();
1725        let client_id =
1726            "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto";
1727
1728        let (real, _) = push_local(
1729            &fixture,
1730            &store,
1731            client_id,
1732            "http://127.0.0.1:4567/callback",
1733        )
1734        .await
1735        .unwrap();
1736        fixture.decisions.settle(&real, DecisionState::Approved);
1737
1738        let (copy, _) = push_local(
1739            &fixture,
1740            &store,
1741            client_id,
1742            "http://127.0.0.1:6000/callback",
1743        )
1744        .await
1745        .unwrap();
1746        let copy = fixture.decisions.get(&copy).unwrap().to_json();
1747        assert_eq!(copy["client"]["origin"], "http://localhost");
1748        assert_eq!(copy["client"]["firstTime"], true);
1749    }
1750
1751    /// **The bypass this closes.** A rule against a local client used to
1752    /// match its `client_id` as written, so another spelling of the same
1753    /// client walked past it. Every spelling is now judged as the one
1754    /// `ClientId` writes, and a parameter the profile does not define is
1755    /// refused outright.
1756    #[tokio::test]
1757    async fn a_rule_against_a_local_client_holds_for_every_spelling_of_it() {
1758        let fixture = Fixture::denying_client(
1759            "^http://localhost/\\?redirect_uri=http%3A%2F%2F127\\.0\\.0\\.1%2Fcallback&scope=atproto$",
1760        );
1761        let store = MemoryParStore::new();
1762        let redirect = "http://127.0.0.1:4567/callback";
1763        for spelling in [
1764            "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto",
1765            "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto",
1766            "http://localhost/?scope=atproto&redirect_uri=http%3A%2F%2F127.0.0.1%3A4567%2Fcallback",
1767            "http://localhost?redirect_uri=http://127.0.0.1:9/callback&scope=atproto+atproto",
1768            "HTTP://LOCALHOST:80/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto",
1769        ] {
1770            assert_eq!(
1771                push_local(&fixture, &store, spelling, redirect).await,
1772                Err(ParError::ClientRefused),
1773                "{spelling}"
1774            );
1775        }
1776        let junk =
1777            "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&scope=atproto&a";
1778        assert!(matches!(
1779            push_local(&fixture, &store, junk, redirect).await,
1780            Err(ParError::ClientId(_))
1781        ));
1782
1783        // The rule names one client, not every local one.
1784        push_local(
1785            &fixture,
1786            &store,
1787            "http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%2Fother&scope=atproto",
1788            "http://127.0.0.1:4567/other",
1789        )
1790        .await
1791        .expect("another local client is not the one the rule names");
1792    }
1793
1794    /// **The lockout this closes.** The bound `plan/oauth.md` needs because
1795    /// PAR is unauthenticated used to drop the newest push, so a stranger
1796    /// naming an account in `login_hint` could keep that account's own
1797    /// sign-ins from ever landing. Past the bound the account's oldest
1798    /// undecided record makes way instead: the newest push is the one that
1799    /// survives, and the displaced one leaves nothing to redeem.
1800    #[tokio::test]
1801    async fn a_push_past_the_accounts_own_bound_displaces_the_oldest_not_the_newest() {
1802        let fixture = Fixture {
1803            decisions: MemoryDecisionStore::with_bounds(1, 100),
1804            ..Fixture::new()
1805        };
1806        let store = MemoryParStore::new();
1807        let (first, _) = push(&fixture, &store, &GrantAnyScope, request())
1808            .await
1809            .unwrap();
1810        // A `403` here would depend on this account already being loaded —
1811        // exactly the fact an unresolvable hint must not be able to probe
1812        // for by comparison. It answers success instead.
1813        let (second, _) = push(&fixture, &store, &GrantAnyScope, request())
1814            .await
1815            .expect("a full per-account bound displaces, it does not refuse");
1816        assert_ne!(first, second);
1817        // The newest push is answerable; the displaced one is gone from both
1818        // stores, exactly as an expired one would be.
1819        assert!(fixture.decisions.get(&second).is_some());
1820        assert!(store.take(&second).is_some());
1821        assert!(fixture.decisions.get(&first).is_none());
1822        assert!(store.take(&first).is_none());
1823    }
1824
1825    /// **The consent-store leak this fix closes.** `consent::begin` mints a
1826    /// reference before `DecisionStore::put` is even asked, since a
1827    /// reference has to name the record it approves. Undoing a record
1828    /// without also discarding that reference left one live
1829    /// `PendingConsent` behind per push past the bound, in a store with no
1830    /// other way to remove one short of it being presented — which nothing
1831    /// ever does for a record that no longer exists. This endpoint is
1832    /// unauthenticated, so that was unbounded memory growth one stranger
1833    /// could trigger against any real account merely by naming it enough.
1834    /// One record in, one record out: the store does not grow.
1835    #[tokio::test]
1836    async fn a_displacing_push_leaves_the_consent_store_at_its_previous_size() {
1837        let fixture = Fixture {
1838            decisions: MemoryDecisionStore::with_bounds(1, 100),
1839            ..Fixture::new()
1840        };
1841        let store = MemoryParStore::new();
1842        push(&fixture, &store, &GrantAnyScope, request())
1843            .await
1844            .expect("the first push fits under the bound");
1845        let after_first = fixture.consent.len();
1846        assert_eq!(after_first, 1, "the first push's own reference is real");
1847
1848        for _ in 0..5 {
1849            push(&fixture, &store, &GrantAnyScope, request())
1850                .await
1851                .expect("displaced past the bound, not refused");
1852            assert_eq!(
1853                fixture.consent.len(),
1854                after_first,
1855                "a push past the bound must not grow the consent store"
1856            );
1857        }
1858    }
1859
1860    /// Round 2's exact reopening, checked directly against `resolve`: eight
1861    /// pushes naming a real account (however addressed — this fixture's
1862    /// `resolve` always answers `AGENT` for anything, standing in for
1863    /// `resolve_identifier` folding a handle, its DID, and a redundant
1864    /// `:port` into one) and a ninth naming nobody must every one answer
1865    /// `201`, with no ninth-push aliasing effect for a caller to time or
1866    /// compare against a nonexistent hint's own ninth push.
1867    #[tokio::test]
1868    async fn aliased_spellings_of_a_real_account_never_produce_a_refusal_either() {
1869        let fixture = Fixture {
1870            decisions: MemoryDecisionStore::with_bounds(1, 100),
1871            ..Fixture::new()
1872        };
1873        let store = MemoryParStore::new();
1874        for _ in 0..8 {
1875            push(&fixture, &store, &GrantAnyScope, request())
1876                .await
1877                .expect("every spelling of a real account still answers 201");
1878        }
1879        let mut unresolved = request();
1880        unresolved.login_hint = Some("did:web:nobody.example".to_owned());
1881        push(&fixture, &store, &GrantAnyScope, unresolved)
1882            .await
1883            .expect("a nonexistent hint answers 201 too, at every step");
1884    }
1885}