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(©).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}