Skip to main content

didbot_serve/
auth.rs

1//! The credential taxonomy: what this server accepts, and what each route
2//! requires. See `plan/auth-types.md`.
3//!
4//! Nothing here is a policy engine — that is deliberately `plan/write-policy.md`
5//! and `plan/policy-store.md`, not this. This is a small, explicit table:
6//! each route names one [`Credential`] it accepts, and one function per kind
7//! resolves a request's `Authorization` header against it. A route cannot be
8//! missing from [`ROUTE_CREDENTIALS`], because the table and the router are
9//! the same list — `routes.rs`'s `xrpc_methods!` macro builds both out of
10//! entries that do not parse without a credential. This module's job is to
11//! say what the kinds mean; `routes.rs` says which route takes which.
12//!
13//! [`Credential`]'s variants are the set. There is no list of them anywhere
14//! else, and a second copy is a copy that will drift, so do not make one.
15//!
16//! # The kinds this server accepts
17//!
18//! - [`Credential::Public`] — no principal needed, and never toggleable: see
19//!   "Why every public route is public" below for the three separate reasons
20//!   a route ends up in this table with this credential, only one of which
21//!   [`Credential::Disclosure`] now replaces.
22//! - [`Credential::AccountToken`] — `com.atproto.repo.*`'s write routes,
23//!   `uploadBlob`, `getServiceAuth` and `getSession`. Minted once, at
24//!   provisioning, bound to one DID; see `didbot_pds::credential`. Not a legacy session and not
25//!   OAuth: an agent has no person to paste an app password in and no
26//!   authorization server to redirect through, so this is the smallest
27//!   credential that lets a provisioning client write as the account it
28//!   just provisioned. The authenticated DID must equal the repository a
29//!   write names — see [`crate::error::ApiError::repo_mismatch`] — so a
30//!   token from one account can never write another's repository.
31//! - [`Credential::Creator`] — `createAccount` alone. The bearer credential
32//!   names the account creating, in one of three shapes `crate::tree::prove`
33//!   resolves: a session this server issued, a JWT signed by a key the
34//!   creator's credential records name, or an OpenID Connect token the
35//!   creator's `#oidc` entry describes. The deployment's operator, who holds
36//!   no account here, presents the operator session instead; see below.
37//!   The one deployment that creates with no credential is the `.localhost`
38//!   development stack, by [`crate::AuthState::unauthenticated_creates`],
39//!   and it creates beneath the deployment's own account.
40//! - [`Credential::Proof`] — `createSession` alone: the two shapes above
41//!   that are not a session, naming a hosted account.
42//! - [`Credential::KeyProofOrOperator`] — `updateCredentials` alone: a JWT
43//!   one of the account's keys signed, or the operator session. See
44//!   `crate::tree::update_credentials`.
45//! - [`Credential::Disclosure`] — `listAccounts`, `listAccountLedgers`,
46//!   `getAccountLedger` and `stats`: disclosure, public by default and
47//!   narrowable per route by this deployment's configuration, resolved by
48//!   [`require_disclosure`]. See [`Disclosure`] for the reasoning this
49//!   default rests on and the shape the toggle takes.
50//!
51//! # There is no operator credential
52//!
53//! No scheme here authenticates "the operator". A server learns which DID
54//! operates it from the `bot.did.operator` record in the operator's *own*
55//! repository, which this server only ever reads — see `plan/handshake.md`
56//! and `crate::operator_poll`. That is the whole point of the handshake:
57//! no operator credential is ever held by, presented to, or comparable by a
58//! PDS, so there is nothing here for one to be stolen from.
59//!
60//! Two consequences this table makes concrete rather than leaving implied:
61//!
62//! - Every account-lifecycle mutation this server serves is *self-service*
63//!   — [`require_self`], an agent token naming the account the request
64//!   names. Acting on somebody else's account over HTTP has no credential
65//!   here and therefore no route.
66//! - A [`Credential::Disclosure`] route that this deployment has closed is
67//!   closed to everyone. There is no credential that still reaches past the
68//!   toggle, so `false` means refused, full stop.
69//!
70//! A human proves control of that DID to this server by signing in over
71//! atproto OAuth — [`crate::operator`], which mints a dashboard session and
72//! keeps no credential belonging to the operator. That session is not a
73//! [`Credential`]: it reaches the dashboard, and `createAccount` for an
74//! account the operator's record names.
75//!
76//! `DPoP` names the OAuth access tokens every [`Credential::AccountToken`]
77//! route and every `atproto-proxy` call accept, through
78//! [`require_agent_token_or_dpop`], which reads the scheme off the headers
79//! itself. Every route resolved through [`present`] folds `DPoP` into
80//! [`Presented::Disabled`], as it does `ServiceAuth`; see
81//! [`DISABLED_SCHEMES`].
82//!
83//! # Why every public route is public
84//!
85//! `plan/auth-types.md` asks for this to be a decision this epic writes
86//! down, not an accident, and it is one of four separate decisions rather
87//! than one:
88//!
89//! - **Public because the protocol requires it.** Every `com.atproto.sync.*`
90//!   route and the read half of `com.atproto.repo.*` — `getRecord`,
91//!   `listRecords`, `describeRepo` — are what a relay crawls and an appview
92//!   indexes. A credential here means no relay indexes these agents and
93//!   nothing they write ever reaches Bluesky, which contradicts what this
94//!   project says it does. This server exists to be read the way a public
95//!   git remote or a public atproto PDS is, and nothing here narrows it:
96//!   whether this deployment federates at all is the operator's decision, made
97//!   and recorded as `Credential::Public` on purpose — `plan/spaces.md`
98//!   names "private means federation off" as the alternative posture a
99//!   future deployment may choose, which is exactly why this is written as a
100//!   decision rather than left looking like nobody got to it.
101//! - **Public because it must be, structurally.** `/.well-known/did.json`
102//!   and `/.well-known/atproto-did` resolve a DID for anyone, which is what
103//!   a DID is; a credential there and the identity premise this whole
104//!   project rests on collapses. `com.atproto.identity.resolveHandle` is
105//!   the same resolution, asked of this server by a client that cannot do
106//!   DNS.
107//! - **Public by default, on purpose, and narrowable.**
108//!   [`Credential::Disclosure`] — see [`Disclosure`] — which is what
109//!   `bot.did.listAccounts`, `listAccountLedgers`, `getAccountLedger` and `stats`
110//!   now are. This is the tier finding 02 actually found: not "public
111//!   because a relay needs it" and not "public because the alternative is
112//!   incoherent", but public because nobody had written the reasoning down
113//!   — and once it is written down, per-route configuration turns out to be
114//!   the right shape, not a hole to close.
115//! - **Public because it confers nothing, and the bound is elsewhere.**
116//!   `bot.did.pollOperatorClaim` alone. It asks this server to read the
117//!   operator's repository sooner than its timer would, and that is its
118//!   whole effect — it cannot assert a claim, only ask the server to go and
119//!   look for one. A credential would be protecting a capability that does
120//!   not exist. What keeps an anonymous caller from turning it into a way to
121//!   make this server work is the floor between two polls, which bounds the
122//!   *outbound* rate rather than the inbound one; see
123//!   `crate::operator_poll::MIN_POLL_INTERVAL`.
124
125use axum::http::{header, HeaderMap, HeaderName, StatusCode};
126use didbot_pds::{Registry, TokenError};
127
128use crate::error::ApiError;
129use crate::oauth::scope::{Action, NsidPattern, Scope, ScopeSet};
130
131/// Compares two secrets in time that does not depend on where they first
132/// differ.
133///
134/// `subtle`, not a hand-rolled fold: a comparison the optimiser is free to
135/// turn back into an early return is a comparison that leaks how much of a
136/// secret a caller guessed right.
137pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
138    use subtle::ConstantTimeEq;
139    a.len() == b.len() && bool::from(a.ct_eq(b))
140}
141
142/// A credential kind a route may declare it accepts.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum Credential {
145    /// No principal required. A deliberate decision, not an absence of one.
146    Public,
147    /// `didbot_pds::credential`'s bearer token, minted at provisioning, or a
148    /// DPoP-bound OAuth access token; see `require_agent_token_or_dpop`.
149    AccountToken,
150    /// An agent's own [`Credential::AccountToken`], resolved to the account it
151    /// authenticates as rather than to a repository path.
152    ///
153    /// Two postures, and which one a route takes depends on whether the
154    /// request names an account at all.
155    ///
156    /// **Checked against the account the request names.** Every
157    /// account-lifecycle mutation: `activateAccount`, `deactivateAccount`,
158    /// `deleteAccount`, `freezeAccount` and `unfreezeAccount`.
159    /// The request carries a DID and the token must authenticate as that
160    /// same account — see `auth::require_self`. A client calls them carrying
161    /// the credential `createAccount` handed back, and an agent tearing
162    /// down or toggling *itself* is the only caller this server can
163    /// authenticate for them.
164    ///
165    /// **Used as identity, with nothing to compare it against.** The four
166    /// authorization-decision routes: `approveAuthorization`,
167    /// `declineAuthorization`, `getAuthorization` and
168    /// `listPendingAuthorizations`. These name no account, so there is no
169    /// comparison to make and none is made: what they read and answer are
170    /// the decisions addressed to the account the presented token
171    /// authenticates as, and an agent cannot ask for anybody else's because
172    /// there is nowhere in the request to ask. `createBrowserSignIn` is the
173    /// same: its ticket signs a browser in as that account.
174    ///
175    /// Either way, there is no operator credential to carve an exception
176    /// for; see the module doc.
177    AccountSelf,
178    /// `createAccount` alone: a credential naming the creator, in any of
179    /// the shapes `crate::tree::prove` resolves. See the module doc.
180    Creator,
181    /// `createSession` alone: a key-signed JWT or an OpenID Connect token
182    /// naming a hosted account; see `crate::tree::prove`.
183    Proof,
184    /// `updateCredentials` alone: a JWT one of the account's keys signed,
185    /// or the operator session with its CSRF header.
186    KeyProofOrOperator,
187    /// Public by default, and narrowable by this deployment's own
188    /// configuration to operator-only — never the reverse. `listAccounts`,
189    /// `listAccountLedgers`, `getAccountLedger` and `stats`: disclosure rather
190    /// than mutation, and this project's accountability story rather than an
191    /// accident — see [`Disclosure`] and `require_disclosure` for the reasoning
192    /// and the mechanism, and `plan/auth-types.md`'s "publish by default" for
193    /// the write-up this epic owes an audience beyond this table.
194    Disclosure,
195}
196
197/// Per-route configuration for every [`Credential::Disclosure`] route: open
198/// (public) or closed (operator-only), one flag per route rather than one
199/// flag for all of them.
200///
201/// # Why public is the default
202///
203/// `listAccounts`, `listAccountLedgers`, `getAccountLedger` and `stats` say which
204/// agents this deployment runs and what happened to them. Publishing that is
205/// this project's accountability story, not an afterthought: `plan/ownership.md`
206/// exists so a stranger can ask whether an agent traces to a responsible
207/// human, and that question has no answer if the deployment that could
208/// answer it is also the one deciding whether to be asked. The same argument
209/// this project makes elsewhere for publishing what it is doing — so
210/// divergence from what was promised is a comparison somebody outside can
211/// make — applies here without modification: an operator who wants a quiet
212/// server may close a route, but the field a fresh deployment starts from is
213/// `true`.
214///
215/// # Why it is still toggleable
216///
217/// This is a decision for now, not a permanent property.
218/// `plan/spaces.md` names "private means federation off" as an alternative
219/// posture a deployment may choose, and disclosure is the same kind of
220/// choice at a smaller grain: a deployment running agents on behalf of
221/// people who would rather not have every account enumerated by a stranger
222/// needs a way to say so. Per-route rather than one switch, because closing
223/// the ledger while leaving the roster open — or the reverse — is a
224/// plausible posture a single flag cannot express.
225///
226/// # The toggle may only narrow
227///
228/// Every field defaults `true`. A deployment's configuration may flip a
229/// field to `false`; nothing may flip one to something wider than `true`,
230/// because there is nothing wider than public — narrowing is the only
231/// direction this configuration tier is ever allowed to move in, the same
232/// rule that governs every other tier below the operator's own repository.
233///
234/// # Closing a route is visible, not silent
235///
236/// The threat this default exists to resist is an operator who cannot be
237/// audited, and the configuration tier that closes a route is a tier an
238/// on-box attacker also reaches — so closing `listAccounts` is exactly what
239/// that attacker would do to hide what a stranger needed to see. This
240/// cannot be prevented from here, but it is made visible:
241/// `auth::require_disclosure` answers a closed route with `DisclosureDisabled`,
242/// never a 404 and never an empty list, so an outsider can tell "this
243/// server runs no agents" from "this server is not telling you" — two very
244/// different facts, and only one of them is suspicious.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub struct Disclosure {
247    /// Whether a stranger may read which accounts this deployment holds.
248    ///
249    /// Every route that names them alike, not `bot.did.listAccounts` on its
250    /// own: `com.atproto.sync.listRepos` pages through the same accounts,
251    /// and `com.atproto.sync.subscribeRepos` announces each one as it
252    /// appears and as it writes. Closing one and leaving the others would
253    /// answer `DisclosureDisabled` over a roster still readable two other
254    /// ways, which is worse than not offering the toggle: it says the
255    /// accounts are hidden when they are not.
256    ///
257    /// Closing it costs federation. A relay crawls `subscribeRepos`, and a
258    /// deployment that refuses it is not indexed — the trade
259    /// `plan/spaces.md` names as "private means federation off", taken at
260    /// this grain.
261    pub list_accounts: bool,
262    /// `bot.did.listAccountLedgers`.
263    pub list_account_ledgers: bool,
264    /// `bot.did.getAccountLedger`.
265    pub get_account_ledger: bool,
266    /// `bot.did.stats`.
267    pub stats: bool,
268}
269
270impl Default for Disclosure {
271    /// Every route open, which is this configuration's own resting state —
272    /// see [`Disclosure`]'s own documentation for why the default is public
273    /// rather than closed.
274    fn default() -> Self {
275        Self {
276            list_accounts: true,
277            list_account_ledgers: true,
278            get_account_ledger: true,
279            stats: true,
280        }
281    }
282}
283
284/// What a request's `Authorization` header actually named.
285#[derive(Debug, Clone, PartialEq, Eq)]
286enum Presented {
287    /// No `Authorization` header, or one this server cannot parse at all.
288    None,
289    /// `Bearer <token>` — the scheme an agent token is presented under.
290    Bearer(String),
291    /// A scheme name this taxonomy knows and has decided not to serve here.
292    Disabled(&'static str),
293    /// A header with no space after its first word: a bare token, or a
294    /// scheme and token split by something other than a space.
295    NoScheme,
296    /// A scheme word nobody defined. The word is not kept: a caller can put
297    /// a token there, and [`authentication_required`] would echo it into the
298    /// response and the journal.
299    Unrecognized,
300}
301
302/// Schemes [`present`] names and refuses with 403 `SchemeDisabled`, so a
303/// client presenting one is told "not enabled" rather than "not a scheme".
304///
305/// `DPoP` is the OAuth access token scheme atproto specifies. The routes
306/// that take it read it off the headers through [`dpop_bearer`] instead of
307/// through [`present`], so every route that resolves its credential here
308/// refuses it.
309///
310/// `ServiceAuth` is refused on every route, and names no token this server
311/// issues: the tokens `com.atproto.server.getServiceAuth` mints are
312/// presented to other services as `Bearer`.
313const DISABLED_SCHEMES: &[(&str, &str)] = &[("DPoP", "DPoP"), ("ServiceAuth", "ServiceAuth")];
314
315fn present(headers: &HeaderMap) -> Presented {
316    let Some(raw) = headers
317        .get(header::AUTHORIZATION)
318        .and_then(|value| value.to_str().ok())
319    else {
320        return Presented::None;
321    };
322    let Some((scheme, rest)) = raw.split_once(' ') else {
323        return Presented::NoScheme;
324    };
325    let token = rest.trim().to_owned();
326    if scheme.eq_ignore_ascii_case("Bearer") {
327        return Presented::Bearer(token);
328    }
329    for (name, canonical) in DISABLED_SCHEMES {
330        if scheme.eq_ignore_ascii_case(name) {
331            return Presented::Disabled(canonical);
332        }
333    }
334    Presented::Unrecognized
335}
336
337/// The header naming which scheme(s) a 401 wants.
338const WWW_AUTHENTICATE: HeaderName = header::WWW_AUTHENTICATE;
339
340/// `AuthenticationRequired`: no credential was presented at all, or the
341/// scheme presented is not one this protocol defines.
342fn authentication_required(hint: &str) -> ApiError {
343    ApiError::new(
344        StatusCode::UNAUTHORIZED,
345        "AuthenticationRequired",
346        format!("this route needs a credential, and none was presented ({hint})"),
347    )
348    .with_header(WWW_AUTHENTICATE, "Bearer")
349}
350
351/// `SchemeDisabled`: the scheme is one this server recognises, and this
352/// route does not accept it. The message names the routes that do.
353///
354/// Its own name and its own status (403, not 401) so a client can tell this
355/// apart from [`authentication_required`] by the name alone: retrying with a
356/// *different* credential of the same kind will not help either, because the
357/// kind itself is off here.
358fn scheme_disabled(scheme: &'static str) -> ApiError {
359    let message = if scheme == "DPoP" {
360        let routes: Vec<&str> = ROUTE_CREDENTIALS
361            .iter()
362            .filter(|(_, credential)| *credential == Credential::AccountToken)
363            .map(|(nsid, _)| *nsid)
364            .collect();
365        format!(
366            "this route does not accept the `DPoP` scheme. These accept it: {}, and any call \
367             carrying `atproto-proxy`",
368            routes.join(", ")
369        )
370    } else {
371        format!("no route accepts the `{scheme}` scheme")
372    };
373    ApiError::new(StatusCode::FORBIDDEN, "SchemeDisabled", message)
374}
375
376/// `DisclosureDisabled`: this deployment has closed a [`Credential::Disclosure`]
377/// route by configuration, so it is closed to everyone.
378///
379/// Its own name, distinct from [`authentication_required`], on purpose: an
380/// outsider reading `AuthenticationRequired` off a route that is public
381/// everywhere else cannot tell "this deployment closed it" from "you forgot
382/// a header", and both look identical to a route that does not exist. A
383/// stranger auditing this deployment — `plan/ownership.md`'s whole premise —
384/// needs to be able to tell "this server runs no agents" from "this server
385/// is not telling you", and only a name of its own says which one happened.
386/// 403, like [`scheme_disabled`]: retrying with a different credential will
387/// not help either, because the deployment closed the route, not the
388/// caller's credential.
389fn disclosure_disabled(route: &str) -> ApiError {
390    ApiError::new(
391        StatusCode::FORBIDDEN,
392        "DisclosureDisabled",
393        format!(
394            "this deployment has closed `{route}` by configuration; this is not evidence that \
395             no agents exist here, only that this deployment chose not to publish it — and no \
396             credential reaches past the toggle, which is closed for every caller alike"
397        ),
398    )
399}
400
401/// `InvalidToken`: the right scheme, a token this server does not recognise.
402fn invalid_token(reason: &str) -> ApiError {
403    ApiError::new(StatusCode::UNAUTHORIZED, "InvalidToken", reason.to_owned())
404        .with_header(WWW_AUTHENTICATE, "Bearer")
405}
406
407/// What a refused `DPoP` credential was refused over, as RFC 9449 §7.1's
408/// `error` names it.
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410enum DpopRefusal {
411    /// The access token: unknown, expired, or bound to another key. A
412    /// client refreshes on this one.
413    Token,
414    /// The proof beside it. A new token would not help.
415    Proof,
416}
417
418/// `InvalidToken` for a `DPoP` credential, challenged in the `DPoP` scheme,
419/// as RFC 9449 §7.1 asks. atproto clients refresh only on a `DPoP`
420/// challenge naming `invalid_token`; a `Bearer` one is read as final.
421fn invalid_dpop(refused: DpopRefusal, reason: &str) -> ApiError {
422    let error = match refused {
423        DpopRefusal::Token => "invalid_token",
424        DpopRefusal::Proof => "invalid_dpop_proof",
425    };
426    ApiError::new(StatusCode::UNAUTHORIZED, "InvalidToken", reason.to_owned()).with_header(
427        WWW_AUTHENTICATE,
428        format!(
429            "DPoP error=\"{error}\", algs=\"{}\"",
430            crate::oauth::dpop::JWT_ALG
431        ),
432    )
433}
434
435/// A refused `DPoP` proof: RFC 9449 §9's `use_dpop_nonce` challenge, with
436/// the nonce to retry with in `DPoP-Nonce`, for a proof that carries no
437/// nonce this server accepts, and [`invalid_dpop`] for any other.
438fn refused_proof(err: crate::oauth::dpop_seam::DpopError) -> ApiError {
439    match err {
440        crate::oauth::dpop_seam::DpopError::UseNonce { nonce, message } => {
441            ApiError::new(StatusCode::UNAUTHORIZED, "UseDpopNonce", message)
442                .with_header(
443                    WWW_AUTHENTICATE,
444                    format!(
445                        "DPoP error=\"use_dpop_nonce\", algs=\"{}\"",
446                        crate::oauth::dpop::JWT_ALG
447                    ),
448                )
449                .with_header(crate::oauth::dpop::DPOP_NONCE, nonce)
450        }
451        err => invalid_dpop(DpopRefusal::Proof, &err.to_string()),
452    }
453}
454
455/// `ExpiredToken`: the right scheme, a token that was once good.
456pub(crate) fn expired_token() -> ApiError {
457    ApiError::new(
458        StatusCode::UNAUTHORIZED,
459        "ExpiredToken",
460        "this token has expired",
461    )
462    .with_header(WWW_AUTHENTICATE, "Bearer")
463}
464
465/// The bearer token the `Authorization` header carries, whatever it turns
466/// out to be: the shared first step of every bearer-taking route.
467pub(crate) fn bearer(headers: &HeaderMap) -> Result<String, ApiError> {
468    match present(headers) {
469        Presented::None => Err(authentication_required("no Authorization header")),
470        Presented::NoScheme => Err(authentication_required(
471            "`<no scheme>`: expected a scheme, one space, then the credential",
472        )),
473        Presented::Unrecognized => Err(authentication_required(
474            "the scheme is not one this server defines",
475        )),
476        Presented::Disabled(scheme) => Err(scheme_disabled(scheme)),
477        Presented::Bearer(token) => Ok(token),
478    }
479}
480
481/// Resolves an agent token from the `Authorization` header, for
482/// `com.atproto.repo.*`'s write routes and `uploadBlob`.
483///
484/// `Bearer <token>`, because atproto has one bearer scheme and the *kind* of
485/// bearer a route wants is which store the token is checked against, not a
486/// separate word in the header. A token [`Registry::verify_agent_token`]
487/// does not recognise is [`invalid_token`].
488pub fn require_agent_token(
489    registry: &dyn Registry,
490    headers: &HeaderMap,
491) -> Result<String, ApiError> {
492    agent_token(registry, headers).inspect_err(|refused| refused_xrpc(refused, None, None))
493}
494
495/// [`require_agent_token`], without writing a refusal down.
496fn agent_token(registry: &dyn Registry, headers: &HeaderMap) -> Result<String, ApiError> {
497    match registry.verify_agent_token(&bearer(headers)?) {
498        Ok(did) => Ok(did),
499        Err(TokenError::Expired) => Err(expired_token()),
500        Err(TokenError::Unknown) => Err(invalid_token(
501            "this token is not an agent token this server issued",
502        )),
503    }
504}
505
506/// The `DPoP` credential scheme's raw pieces, straight off the
507/// `Authorization` and `DPoP` headers — deliberately not routed through
508/// [`present`]/[`Presented`], which still folds `DPoP` into
509/// [`Presented::Disabled`] for every other route in this module that has
510/// not opted into accepting it (see [`DISABLED_SCHEMES`]'s own doc). Kept to
511/// exactly the two headers a caller presenting this scheme sends, mirroring
512/// [`present`]'s own header-splitting rather than growing that function a
513/// third scheme every existing call site would need a new match arm for.
514///
515/// `None` when the credential is not in the `DPoP` scheme. A `DPoP`
516/// credential beside no proof, or more than one, is refused as a bad proof
517/// (RFC 9449 §4.3, §7.1).
518fn dpop_bearer(headers: &HeaderMap) -> Result<Option<(String, &str)>, ApiError> {
519    let Some((scheme, rest)) = headers
520        .get(header::AUTHORIZATION)
521        .and_then(|value| value.to_str().ok())
522        .and_then(|raw| raw.split_once(' '))
523    else {
524        return Ok(None);
525    };
526    if !scheme.eq_ignore_ascii_case("DPoP") {
527        return Ok(None);
528    }
529    let proof = crate::oauth::dpop::presented_proof(headers)
530        .map_err(|err| invalid_dpop(DpopRefusal::Proof, &err.to_string()))?
531        .ok_or_else(|| {
532            invalid_dpop(
533                DpopRefusal::Proof,
534                "a `DPoP` access token needs a `DPoP` proof header beside it",
535            )
536        })?;
537    Ok(Some((rest.trim().to_owned(), proof)))
538}
539
540/// Resolves either an agent's own [`Credential::AccountToken`] or a DPoP-bound
541/// OAuth access token minted by `crate::oauth::token`, for
542/// `com.atproto.repo.*`'s write routes and `uploadBlob` — the resource path
543/// an app authorized over OAuth actually writes through.
544///
545/// `http_method` and `path` are this request's own method and route path
546/// (e.g. `"/xrpc/com.atproto.repo.createRecord"`); a DPoP proof presented
547/// here must bind to *this* request, not merely to some request the caller
548/// once made. The absolute `htu` a proof is checked against is derived from
549/// `path` the same way [`crate::oauth::discovery::request_htu`] builds it
550/// for `/oauth/token` — out of this deployment's own zone service document,
551/// never a header — and, deliberately, only when a `DPoP`-scheme credential
552/// is actually presented: an agent-token route with no zone document seeded
553/// yet (a bare `FakeRegistry`, or a deployment mid-bootstrap) must still be
554/// reachable with a plain agent token, which needs no `htu` at all.
555///
556/// A `DPoP <token>` credential is checked in two steps, both required: the
557/// `DPoP` header's proof must verify against this request
558/// ([`crate::oauth::dpop_seam::DpopVerifier::verify`]), and the resulting
559/// thumbprint must match the one `access_token` was minted bound to
560/// ([`crate::oauth::token::OAuthTokenStore::validate_access`]). A bare
561/// stolen token with no proof, or a well-formed proof signed by the wrong
562/// key, fails at one of those two steps — see `plan/adversarial.md`'s
563/// DPoP-binding findings, which this function exists to close.
564pub fn require_agent_token_or_dpop(
565    registry: &dyn Registry,
566    oauth: &crate::oauth::OAuthState,
567    headers: &HeaderMap,
568    http_method: &str,
569    path: &str,
570) -> Result<Writer, ApiError> {
571    agent_token_or_dpop(registry, oauth, headers, http_method, path)
572        .inspect_err(|refused| refused_xrpc(refused, None, None))
573}
574
575/// [`require_agent_token_or_dpop`], without writing a refusal down.
576fn agent_token_or_dpop(
577    registry: &dyn Registry,
578    oauth: &crate::oauth::OAuthState,
579    headers: &HeaderMap,
580    http_method: &str,
581    path: &str,
582) -> Result<Writer, ApiError> {
583    if let Some((token, proof)) = dpop_bearer(headers)? {
584        let htu = crate::oauth::discovery::request_htu(registry, path).map_err(|err| {
585            ApiError::new(
586                StatusCode::INTERNAL_SERVER_ERROR,
587                "ServerError",
588                err.to_string(),
589            )
590        })?;
591        // The token goes in: RFC 9449 §7.1's `ath` check, which is what
592        // binds this proof to *this* access token rather than to any request
593        // the caller's key once signed.
594        let verified = oauth
595            .dpop
596            .verify(proof, http_method, &htu, Some(&token))
597            .map_err(refused_proof)?;
598        let grant = oauth
599            .tokens
600            .validate_access(&token, &verified.thumbprint, oauth.scope_policy.as_ref())
601            .ok_or_else(|| {
602                invalid_dpop(
603                    DpopRefusal::Token,
604                    "this access token is not one this server issued, has expired, or does not \
605                 match the key the presented DPoP proof proves possession of",
606                )
607            })?;
608        // The token checked out, so this proof is one an authenticated
609        // caller presented: now it costs a replay-window entry, and
610        // presenting it a second time is refused.
611        oauth
612            .dpop
613            .accept(&verified)
614            .map_err(|err| invalid_dpop(DpopRefusal::Proof, &err.to_string()))?;
615        return Ok(Writer {
616            did: grant.did,
617            token: Some((grant.client_id, grant.scope)),
618        });
619    }
620    agent_token(registry, headers).map(|did| Writer { did, token: None })
621}
622
623/// Who a repo write speaks for, as [`require_agent_token_or_dpop`] resolved
624/// it.
625///
626/// What a write needs past the account is reached through
627/// [`Writer::authorize`] alone, so a route cannot name the client to the
628/// write path without first checking the token's scope.
629#[derive(Debug, Clone, PartialEq, Eq)]
630pub struct Writer {
631    did: String,
632    /// The application whose OAuth token was presented, and the scope that
633    /// token was granted. `None` for the account's own agent token.
634    token: Option<(didbot_pds::policy::ClientId, ScopeSet)>,
635}
636
637/// A write [`Writer::authorize`] found inside the credential's scope.
638#[derive(Debug, Clone, Copy, PartialEq, Eq)]
639pub struct Authorized<'a> {
640    /// The account writing.
641    pub did: &'a str,
642    /// The application whose token presented the write, for the policy
643    /// that judges it. `None` for the account's own agent token.
644    pub client_id: Option<&'a didbot_pds::policy::ClientId>,
645}
646
647/// What a request would have done, for the one it was refused over.
648#[derive(Debug, Clone, PartialEq, Eq)]
649pub enum Wanted {
650    /// A record write.
651    Record {
652        /// The collection written.
653        collection: String,
654        /// What the write would have done there.
655        action: Action,
656    },
657    /// A blob upload.
658    Upload {
659        /// The type the uploader declared.
660        mime_type: String,
661    },
662    /// A call at another service, or a service-auth token for one.
663    Rpc {
664        /// The method, or `None` for a token bound to no method.
665        lxm: Option<String>,
666        /// The service, as a DID or a DID and a service fragment.
667        aud: String,
668    },
669}
670
671/// The one thing a token's scope did not cover, which
672/// [`insufficient_scope`] names.
673#[derive(Debug, Clone, PartialEq, Eq)]
674pub struct ScopeMissing {
675    /// The application whose token lacked it.
676    pub client_id: didbot_pds::policy::ClientId,
677    /// What it was trying to do.
678    pub wanted: Wanted,
679}
680
681impl ScopeMissing {
682    /// The scope it needed.
683    #[must_use]
684    pub fn needed(&self) -> Scope {
685        match &self.wanted {
686            Wanted::Record { collection, action } => Scope::repo_write(collection, *action),
687            Wanted::Upload { mime_type } => Scope::blob_upload(mime_type),
688            Wanted::Rpc { lxm, aud } => Scope::Rpc {
689                method: lxm.clone().map_or(NsidPattern::Any, NsidPattern::Exact),
690                aud: Some(aud.clone()),
691            },
692        }
693    }
694}
695
696/// What the evaluation log names, where a policy's row names the policy,
697/// for a write refused because its token's scope does not cover it.
698pub const TOKEN_SCOPE_RULE: &str = "token-scope";
699
700impl Writer {
701    /// The account the credential authenticates as.
702    #[must_use]
703    pub fn did(&self) -> &str {
704        &self.did
705    }
706
707    /// Checks every `(collection, action)` a request would write against
708    /// the token's scope. The account's own agent token is not scoped: what
709    /// it may write is policy's question alone.
710    ///
711    /// # Errors
712    ///
713    /// The first write the token's scope does not cover.
714    pub fn authorize<'a>(
715        &'a self,
716        writes: &[(&str, Action)],
717    ) -> Result<Authorized<'a>, ScopeMissing> {
718        let Some((client_id, scope)) = &self.token else {
719            return Ok(Authorized {
720                did: &self.did,
721                client_id: None,
722            });
723        };
724        if let Some((collection, action)) = writes
725            .iter()
726            .find(|(collection, action)| !scope.covers(&Scope::repo_write(collection, *action)))
727        {
728            return Err(ScopeMissing {
729                client_id: client_id.clone(),
730                wanted: Wanted::Record {
731                    collection: (*collection).to_owned(),
732                    action: *action,
733                },
734            });
735        }
736        Ok(Authorized {
737            did: &self.did,
738            client_id: Some(client_id),
739        })
740    }
741
742    /// Checks one upload's declared type against the token's scope. How
743    /// large a blob may be is policy's and the server's, not the grant's.
744    ///
745    /// # Errors
746    ///
747    /// The upload, when the token's scope does not cover it.
748    pub fn authorize_blob(&self, mime_type: &str) -> Result<Authorized<'_>, ScopeMissing> {
749        let Some((client_id, scope)) = &self.token else {
750            return Ok(Authorized {
751                did: &self.did,
752                client_id: None,
753            });
754        };
755        if !scope.covers(&Scope::blob_upload(mime_type)) {
756            return Err(ScopeMissing {
757                client_id: client_id.clone(),
758                wanted: Wanted::Upload {
759                    mime_type: mime_type.to_owned(),
760                },
761            });
762        }
763        Ok(Authorized {
764            did: &self.did,
765            client_id: Some(client_id),
766        })
767    }
768
769    /// Checks acting at another service against the token's scope: calling
770    /// `lxm` at `aud`, or taking a service-auth token for it. The account's
771    /// own agent token acts anywhere.
772    ///
773    /// # Errors
774    ///
775    /// The call, when the token's scope does not permit it; see
776    /// [`ScopeSet::permits_rpc`].
777    pub fn authorize_rpc(
778        &self,
779        lxm: Option<&str>,
780        aud: &str,
781    ) -> Result<Authorized<'_>, ScopeMissing> {
782        let Some((client_id, scope)) = &self.token else {
783            return Ok(Authorized {
784                did: &self.did,
785                client_id: None,
786            });
787        };
788        if !scope.permits_rpc(lxm, aud) {
789            return Err(ScopeMissing {
790                client_id: client_id.clone(),
791                wanted: Wanted::Rpc {
792                    lxm: lxm.map(str::to_owned),
793                    aud: aud.to_owned(),
794                },
795            });
796        }
797        Ok(Authorized {
798            did: &self.did,
799            client_id: Some(client_id),
800        })
801    }
802}
803
804/// `InsufficientScope`: a real token, asked to do what it was not granted.
805/// RFC 6750's `insufficient_scope`, in the DPoP scheme the token was
806/// presented under, naming the scope the write needed. Written to the
807/// journal with the client and `did`, the account the token speaks for.
808pub fn insufficient_scope(did: &str, missing: &ScopeMissing) -> ApiError {
809    let refused = ApiError::new(
810        StatusCode::FORBIDDEN,
811        "InsufficientScope",
812        format!("this token's scope does not cover `{}`", missing.needed()),
813    )
814    .with_header(
815        WWW_AUTHENTICATE,
816        format!(
817            "DPoP error=\"insufficient_scope\", scope=\"{}\"",
818            missing.needed()
819        ),
820    );
821    refused_xrpc(&refused, Some(missing.client_id.as_str()), Some(did));
822    refused
823}
824
825/// Writes an XRPC credential refusal to the journal; see `crate::refusal`.
826///
827/// `UseDpopNonce` is the first answer every DPoP client is given, and it
828/// retries at once, so it is not written.
829fn refused_xrpc(refused: &ApiError, client_id: Option<&str>, account: Option<&str>) {
830    if refused.error() == "UseDpopNonce" {
831        return;
832    }
833    crate::refusal::refused(crate::refusal::Refusal {
834        endpoint: "xrpc",
835        client_id,
836        account,
837        error: refused.error(),
838        reason: refused.message(),
839        severity: crate::refusal::Severity::Info,
840    });
841}
842
843/// Resolves an agent's own credential against the account a request names,
844/// for every account-lifecycle mutation this server serves.
845///
846/// `target` is the account the request names — the `did` field of the body.
847/// An agent token authenticates only when it names that same account, which
848/// is [`ApiError::account_mismatch`] otherwise: these routes are
849/// self-service and nothing else. Acting on *another* account over HTTP
850/// would need a credential proving this deployment's operator, and there is
851/// no such credential — a server reads who operates it out of the
852/// operator's own repository and never holds anything presentable back to
853/// it. See the module doc's "There is no operator credential", and
854/// `Registry::hard_delete` for the one lifecycle operation that
855/// consequently has no route at all.
856///
857/// Same failure shapes as [`require_agent_token`] for everything short of the
858/// account check: no header or an unrecognised scheme is
859/// [`authentication_required`], a defined-but-disabled scheme is
860/// [`scheme_disabled`], and a well-formed credential of the wrong kind or
861/// value is [`invalid_token`].
862pub fn require_self(
863    registry: &dyn Registry,
864    headers: &HeaderMap,
865    target: &str,
866) -> Result<(), ApiError> {
867    own_account(registry, headers, target)
868        .inspect_err(|refused| refused_xrpc(refused, None, Some(target)))
869}
870
871/// [`require_self`], without writing a refusal down.
872fn own_account(registry: &dyn Registry, headers: &HeaderMap, target: &str) -> Result<(), ApiError> {
873    match registry.verify_agent_token(&bearer(headers)?) {
874        Ok(did) if did == target => Ok(()),
875        Ok(did) => Err(ApiError::account_mismatch(&did, target)),
876        Err(TokenError::Expired) => Err(expired_token()),
877        Err(TokenError::Unknown) => Err(invalid_token(
878            "this token is not an agent token this server issued",
879        )),
880    }
881}
882
883/// Resolves a [`Credential::Disclosure`] route: open unless this
884/// deployment's configuration has closed it, in which case it is closed to
885/// everyone.
886///
887/// `open` is the one field of [`Disclosure`] this route's toggle lives at,
888/// and it is the whole of the decision — this function reads no header at
889/// all. `true` — the default — is public, full stop, the same as
890/// [`Credential::Public`]. `false` is [`disclosure_disabled`] for every
891/// caller alike: there is no operator credential that could reach past the
892/// toggle (see the module doc), so a deployment that closes a disclosure
893/// route closes it to itself over HTTP too, and an operator who wants the
894/// data back turns the toggle back on rather than presenting something.
895///
896/// One refusal rather than the usual [`authentication_required`] versus
897/// [`invalid_token`] split, which was already true when a credential could
898/// reach past this and is more plainly true now: the caller only needs to
899/// learn one thing here, that this route is closed by configuration and not
900/// merely guarded by a login it forgot.
901pub fn require_disclosure(open: bool, route: &str) -> Result<(), ApiError> {
902    if open {
903        return Ok(());
904    }
905    Err(disclosure_disabled(route))
906}
907
908/// Every `com.atproto.*`, `app.bsky.*` and `bot.did.*` method this router
909/// serves, and the credential it declares. See the module doc for what each kind means.
910///
911/// This is `plan/auth-types.md`'s "one extractor, and a per-route
912/// declaration of what it accepts", and it is **not written here**: it is
913/// assembled from `routes::ATPROTO_CREDENTIALS`,
914/// `routes::APP_BSKY_CREDENTIALS` and `routes::BOT_DID_CREDENTIALS`, which
915/// the `xrpc_methods!` macro emits from
916/// the same entries it builds the router out of. A route therefore cannot
917/// exist without a declaration — the macro arm does not accept an entry that
918/// omits one — and a declaration cannot outlive the route it names, because
919/// deleting the entry deletes both. Neither direction of drift is
920/// representable, which is why nothing below is a runtime check.
921///
922/// Where each kind's reasoning lives: [`Credential`]'s variants for what a
923/// kind is, this module's "Why every public route is public" for the three
924/// separate reasons a route is `Public`, and the three macro invocations in
925/// `routes.rs` for which routes take what.
926pub const ROUTE_CREDENTIALS: &[(&str, Credential)] = &ROUTE_CREDENTIAL_TABLE;
927
928/// The backing array [`ROUTE_CREDENTIALS`] borrows.
929///
930/// A `const fn` rather than a literal because the parts come from three
931/// macro invocations and `const` slice concatenation is not otherwise
932/// available.
933const ROUTE_CREDENTIAL_TABLE: [(&str, Credential); ROUTE_CREDENTIAL_COUNT] = route_credentials();
934
935const ROUTE_CREDENTIAL_COUNT: usize = crate::routes::ATPROTO_CREDENTIALS.len()
936    + crate::routes::APP_BSKY_CREDENTIALS.len()
937    + crate::routes::BOT_DID_CREDENTIALS.len();
938
939const fn route_credentials() -> [(&'static str, Credential); ROUTE_CREDENTIAL_COUNT] {
940    let halves = [
941        crate::routes::ATPROTO_CREDENTIALS,
942        crate::routes::APP_BSKY_CREDENTIALS,
943        crate::routes::BOT_DID_CREDENTIALS,
944    ];
945    let mut table = [("", Credential::Public); ROUTE_CREDENTIAL_COUNT];
946    let mut at = 0;
947    let mut half = 0;
948    while half < halves.len() {
949        let mut i = 0;
950        while i < halves[half].len() {
951            table[at] = halves[half][i];
952            at += 1;
953            i += 1;
954        }
955        half += 1;
956    }
957    table
958}
959
960/// The credential a route declares.
961///
962/// `None` only for an NSID this server does not serve: every route it does
963/// serve is in the table by construction — see [`ROUTE_CREDENTIALS`].
964#[cfg(test)]
965pub fn declared(nsid: &str) -> Option<Credential> {
966    ROUTE_CREDENTIALS
967        .iter()
968        .find(|(name, _)| *name == nsid)
969        .map(|(_, credential)| *credential)
970}
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975    use axum::http::HeaderValue;
976
977    fn headers_with_auth(value: &str) -> HeaderMap {
978        let mut headers = HeaderMap::new();
979        headers.insert(header::AUTHORIZATION, HeaderValue::from_str(value).unwrap());
980        headers
981    }
982
983    /// The credential table and the served NSIDs agree in both directions.
984    ///
985    /// Both come from the same macro entries, so this cannot fail while the
986    /// macro is the only way routes are declared. It is here to fail if
987    /// somebody stops using it — a hand-written `.route()` call added
988    /// alongside the macro, or an entry hand-added to one list and not the
989    /// other. `crate::tests::every_served_route_has_a_credential` is the
990    /// half this cannot do: it reads the router itself rather than either
991    /// list.
992    #[test]
993    fn the_credential_table_is_exactly_the_served_routes() {
994        let served: Vec<&str> = crate::routes::ATPROTO_METHODS
995            .iter()
996            .chain(crate::routes::APP_BSKY_METHODS)
997            .chain(crate::routes::BOT_DID_METHODS)
998            .copied()
999            .collect();
1000        for nsid in &served {
1001            assert!(
1002                declared(nsid).is_some(),
1003                "{nsid} is served and has no entry in ROUTE_CREDENTIALS"
1004            );
1005        }
1006        for (nsid, _) in ROUTE_CREDENTIALS {
1007            assert!(
1008                served.contains(nsid),
1009                "{nsid} has a declared credential and is not served"
1010            );
1011        }
1012    }
1013
1014    #[test]
1015    fn no_credential_is_authentication_required() {
1016        let registry = crate::tests::FakeRegistry::new();
1017        let err = require_agent_token(&registry, &HeaderMap::new()).unwrap_err();
1018        assert_eq!(err.error(), "AuthenticationRequired");
1019        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
1020    }
1021
1022    #[test]
1023    fn an_unrecognised_scheme_is_authentication_required() {
1024        let registry = crate::tests::FakeRegistry::new();
1025        let headers = headers_with_auth("Digest abc123");
1026        let err = require_agent_token(&registry, &headers).unwrap_err();
1027        assert_eq!(err.error(), "AuthenticationRequired");
1028    }
1029
1030    /// The distinguishability test `plan/auth-types.md` calls for by name:
1031    /// an unrecognised scheme and a recognised-but-disabled one must not
1032    /// read the same way.
1033    #[test]
1034    fn a_disabled_scheme_reads_differently_from_an_unrecognised_one() {
1035        let registry = crate::tests::FakeRegistry::new();
1036        let unrecognised =
1037            require_agent_token(&registry, &headers_with_auth("Digest abc")).unwrap_err();
1038        let disabled = require_agent_token(&registry, &headers_with_auth("DPoP abc")).unwrap_err();
1039        assert_ne!(unrecognised.error(), disabled.error());
1040        assert_ne!(unrecognised.status(), disabled.status());
1041        assert_eq!(disabled.error(), "SchemeDisabled");
1042        assert_eq!(disabled.status(), StatusCode::FORBIDDEN);
1043
1044        let service_auth =
1045            require_agent_token(&registry, &headers_with_auth("ServiceAuth abc")).unwrap_err();
1046        assert_eq!(service_auth.error(), "SchemeDisabled");
1047    }
1048
1049    #[test]
1050    fn an_unknown_bearer_token_is_invalid_not_authentication_required() {
1051        let registry = crate::tests::FakeRegistry::new();
1052        let headers = headers_with_auth("Bearer not-a-real-token");
1053        let err = require_agent_token(&registry, &headers).unwrap_err();
1054        assert_eq!(err.error(), "InvalidToken");
1055    }
1056
1057    /// The removal this module's "There is no operator credential" section
1058    /// describes, asserted rather than only written down: `Operator` is not
1059    /// a scheme this server defines, so presenting one reads exactly like
1060    /// presenting `Digest` or any other word nobody defined — and, in
1061    /// particular, never like a credential that merely failed to match.
1062    #[test]
1063    fn the_operator_scheme_is_not_a_scheme_this_server_defines() {
1064        let registry = crate::tests::FakeRegistry::new();
1065        let operator =
1066            require_agent_token(&registry, &headers_with_auth("Operator swordfish")).unwrap_err();
1067        let unknown =
1068            require_agent_token(&registry, &headers_with_auth("Digest swordfish")).unwrap_err();
1069        assert_eq!(operator.error(), "AuthenticationRequired");
1070        assert_eq!(operator.error(), unknown.error());
1071        assert_eq!(operator.status(), unknown.status());
1072    }
1073
1074    /// A closed disclosure route is closed to everyone: no credential, and
1075    /// specifically no `Operator` one, reaches past the toggle.
1076    #[test]
1077    fn a_closed_disclosure_route_refuses_every_caller() {
1078        assert!(require_disclosure(true, "bot.did.listAccounts").is_ok());
1079        let err = require_disclosure(false, "bot.did.listAccounts").unwrap_err();
1080        assert_eq!(err.error(), "DisclosureDisabled");
1081        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1082    }
1083
1084    /// Hard delete is the one account-lifecycle operation with no route,
1085    /// because it is the one that must not be self-service and this server
1086    /// has no administrator credential. A future branch that adds the route
1087    /// back has to change this test to do it.
1088    #[test]
1089    fn hard_delete_has_no_route_and_no_declared_credential() {
1090        assert!(declared("bot.did.hardDeleteAccount").is_none());
1091        assert!(!crate::routes::BOT_DID_METHODS.contains(&"bot.did.hardDeleteAccount"));
1092    }
1093
1094    #[test]
1095    fn disclosure_defaults_to_every_route_open() {
1096        assert_eq!(
1097            Disclosure::default(),
1098            Disclosure {
1099                list_accounts: true,
1100                list_account_ledgers: true,
1101                get_account_ledger: true,
1102                stats: true,
1103            }
1104        );
1105    }
1106
1107    #[test]
1108    fn an_open_disclosure_route_needs_no_credential_at_all() {
1109        // An open route is public, full stop. `require_disclosure` reads no
1110        // header at all now, so there is nothing a caller could present
1111        // that would change this either way.
1112        assert!(require_disclosure(true, "bot.did.stats").is_ok());
1113    }
1114
1115    #[test]
1116    fn a_closed_disclosure_route_is_disclosure_disabled_not_authentication_required() {
1117        let err = require_disclosure(false, "bot.did.stats").unwrap_err();
1118        assert_eq!(err.error(), "DisclosureDisabled");
1119        assert_eq!(err.status(), StatusCode::FORBIDDEN);
1120    }
1121
1122    /// A closed route used to answer a valid `Operator` credential. It
1123    /// answers nobody now — including a caller presenting the scheme that
1124    /// used to work, which must read as closed rather than as a login it
1125    /// got wrong.
1126    #[test]
1127    fn a_closed_disclosure_route_answers_nobody() {
1128        for header in [
1129            "Operator swordfish",
1130            "Bearer swordfish",
1131            "Nonsense whatever",
1132        ] {
1133            let _ = headers_with_auth(header);
1134            let err = require_disclosure(false, "bot.did.stats").unwrap_err();
1135            assert_eq!(err.error(), "DisclosureDisabled");
1136        }
1137    }
1138
1139    /// The three tree routes declare the credentials they read, and nothing
1140    /// else declares them.
1141    #[test]
1142    fn the_tree_routes_declare_their_own_credentials() {
1143        assert_eq!(declared("bot.did.createAccount"), Some(Credential::Creator));
1144        assert_eq!(declared("bot.did.createSession"), Some(Credential::Proof));
1145        assert_eq!(declared("bot.did.parkKey"), Some(Credential::Public));
1146        assert_eq!(
1147            declared("bot.did.updateCredentials"),
1148            Some(Credential::KeyProofOrOperator)
1149        );
1150        assert_eq!(
1151            ROUTE_CREDENTIALS
1152                .iter()
1153                .filter(|(_, credential)| matches!(
1154                    credential,
1155                    Credential::Creator | Credential::Proof | Credential::KeyProofOrOperator
1156                ))
1157                .count(),
1158            3
1159        );
1160    }
1161
1162    #[test]
1163    fn disclosure_disabled_reads_differently_from_authentication_required() {
1164        let closed = require_disclosure(false, "bot.did.stats").unwrap_err();
1165        let unauthenticated =
1166            require_agent_token(&crate::tests::FakeRegistry::new(), &HeaderMap::new()).unwrap_err();
1167        assert_ne!(closed.error(), unauthenticated.error());
1168    }
1169}