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(®istry, &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(®istry, &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(®istry, &headers_with_auth("Digest abc")).unwrap_err();
1038 let disabled = require_agent_token(®istry, &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(®istry, &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(®istry, &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(®istry, &headers_with_auth("Operator swordfish")).unwrap_err();
1067 let unknown =
1068 require_agent_token(®istry, &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}