Expand description
The credential taxonomy: what this server accepts, and what each route
requires. See plan/auth-types.md.
Nothing here is a policy engine — that is deliberately plan/write-policy.md
and plan/policy-store.md, not this. This is a small, explicit table:
each route names one Credential it accepts, and one function per kind
resolves a request’s Authorization header against it. A route cannot be
missing from ROUTE_CREDENTIALS, because the table and the router are
the same list — routes.rs’s xrpc_methods! macro builds both out of
entries that do not parse without a credential. This module’s job is to
say what the kinds mean; routes.rs says which route takes which.
Credential’s variants are the set. There is no list of them anywhere
else, and a second copy is a copy that will drift, so do not make one.
§The kinds this server accepts
Credential::Public— no principal needed, and never toggleable: see “Why every public route is public” below for the three separate reasons a route ends up in this table with this credential, only one of whichCredential::Disclosurenow replaces.Credential::AccountToken—com.atproto.repo.*’s write routes,uploadBlob,getServiceAuthandgetSession. Minted once, at provisioning, bound to one DID; seedidbot_pds::credential. Not a legacy session and not OAuth: an agent has no person to paste an app password in and no authorization server to redirect through, so this is the smallest credential that lets a provisioning client write as the account it just provisioned. The authenticated DID must equal the repository a write names — seecrate::error::ApiError::repo_mismatch— so a token from one account can never write another’s repository.Credential::Creator—createAccountalone. The bearer credential names the account creating, in one of three shapescrate::tree::proveresolves: a session this server issued, a JWT signed by a key the creator’s credential records name, or an OpenID Connect token the creator’s#oidcentry describes. The deployment’s operator, who holds no account here, presents the operator session instead; see below. The one deployment that creates with no credential is the.localhostdevelopment stack, bycrate::AuthState::unauthenticated_creates, and it creates beneath the deployment’s own account.Credential::Proof—createSessionalone: the two shapes above that are not a session, naming a hosted account.Credential::KeyProofOrOperator—updateCredentialsalone: a JWT one of the account’s keys signed, or the operator session. Seecrate::tree::update_credentials.Credential::Disclosure—listAccounts,listAccountLedgers,getAccountLedgerandstats: disclosure, public by default and narrowable per route by this deployment’s configuration, resolved byrequire_disclosure. SeeDisclosurefor the reasoning this default rests on and the shape the toggle takes.
§There is no operator credential
No scheme here authenticates “the operator”. A server learns which DID
operates it from the bot.did.operator record in the operator’s own
repository, which this server only ever reads — see plan/handshake.md
and crate::operator_poll. That is the whole point of the handshake:
no operator credential is ever held by, presented to, or comparable by a
PDS, so there is nothing here for one to be stolen from.
Two consequences this table makes concrete rather than leaving implied:
- Every account-lifecycle mutation this server serves is self-service
—
require_self, an agent token naming the account the request names. Acting on somebody else’s account over HTTP has no credential here and therefore no route. - A
Credential::Disclosureroute that this deployment has closed is closed to everyone. There is no credential that still reaches past the toggle, sofalsemeans refused, full stop.
A human proves control of that DID to this server by signing in over
atproto OAuth — crate::operator, which mints a dashboard session and
keeps no credential belonging to the operator. That session is not a
Credential: it reaches the dashboard, and createAccount for an
account the operator’s record names.
DPoP names the OAuth access tokens every Credential::AccountToken
route and every atproto-proxy call accept, through
require_agent_token_or_dpop, which reads the scheme off the headers
itself. Every route resolved through present folds DPoP into
Presented::Disabled, as it does ServiceAuth; see
DISABLED_SCHEMES.
§Why every public route is public
plan/auth-types.md asks for this to be a decision this epic writes
down, not an accident, and it is one of four separate decisions rather
than one:
- Public because the protocol requires it. Every
com.atproto.sync.*route and the read half ofcom.atproto.repo.*—getRecord,listRecords,describeRepo— are what a relay crawls and an appview indexes. A credential here means no relay indexes these agents and nothing they write ever reaches Bluesky, which contradicts what this project says it does. This server exists to be read the way a public git remote or a public atproto PDS is, and nothing here narrows it: whether this deployment federates at all is the operator’s decision, made and recorded asCredential::Publicon purpose —plan/spaces.mdnames “private means federation off” as the alternative posture a future deployment may choose, which is exactly why this is written as a decision rather than left looking like nobody got to it. - Public because it must be, structurally.
/.well-known/did.jsonand/.well-known/atproto-didresolve a DID for anyone, which is what a DID is; a credential there and the identity premise this whole project rests on collapses.com.atproto.identity.resolveHandleis the same resolution, asked of this server by a client that cannot do DNS. - Public by default, on purpose, and narrowable.
Credential::Disclosure— seeDisclosure— which is whatbot.did.listAccounts,listAccountLedgers,getAccountLedgerandstatsnow are. This is the tier finding 02 actually found: not “public because a relay needs it” and not “public because the alternative is incoherent”, but public because nobody had written the reasoning down — and once it is written down, per-route configuration turns out to be the right shape, not a hole to close. - Public because it confers nothing, and the bound is elsewhere.
bot.did.pollOperatorClaimalone. It asks this server to read the operator’s repository sooner than its timer would, and that is its whole effect — it cannot assert a claim, only ask the server to go and look for one. A credential would be protecting a capability that does not exist. What keeps an anonymous caller from turning it into a way to make this server work is the floor between two polls, which bounds the outbound rate rather than the inbound one; seecrate::operator_poll::MIN_POLL_INTERVAL.
Structs§
- Authorized
- A write
Writer::authorizefound inside the credential’s scope. - Disclosure
- Per-route configuration for every
Credential::Disclosureroute: open (public) or closed (operator-only), one flag per route rather than one flag for all of them. - Scope
Missing - The one thing a token’s scope did not cover, which
insufficient_scopenames. - Writer
- Who a repo write speaks for, as
require_agent_token_or_dpopresolved it.
Enums§
- Credential
- A credential kind a route may declare it accepts.
- Dpop
Refusal 🔒 - What a refused
DPoPcredential was refused over, as RFC 9449 §7.1’serrornames it. - Presented 🔒
- What a request’s
Authorizationheader actually named. - Wanted
- What a request would have done, for the one it was refused over.
Constants§
- DISABLED_
SCHEMES 🔒 - Schemes
presentnames and refuses with 403SchemeDisabled, so a client presenting one is told “not enabled” rather than “not a scheme”. - ROUTE_
CREDENTIALS - Every
com.atproto.*,app.bsky.*andbot.did.*method this router serves, and the credential it declares. See the module doc for what each kind means. - ROUTE_
CREDENTIAL_ 🔒COUNT - ROUTE_
CREDENTIAL_ 🔒TABLE - The backing array
ROUTE_CREDENTIALSborrows. - TOKEN_
SCOPE_ RULE - What the evaluation log names, where a policy’s row names the policy, for a write refused because its token’s scope does not cover it.
- WWW_
AUTHENTICATE 🔒 - The header naming which scheme(s) a 401 wants.
Functions§
- agent_
token 🔒 require_agent_token, without writing a refusal down.- agent_
token_ 🔒or_ dpop require_agent_token_or_dpop, without writing a refusal down.- authentication_
required 🔒 AuthenticationRequired: no credential was presented at all, or the scheme presented is not one this protocol defines.- bearer 🔒
- The bearer token the
Authorizationheader carries, whatever it turns out to be: the shared first step of every bearer-taking route. - constant_
time_ 🔒eq - Compares two secrets in time that does not depend on where they first differ.
- disclosure_
disabled 🔒 DisclosureDisabled: this deployment has closed aCredential::Disclosureroute by configuration, so it is closed to everyone.- dpop_
bearer 🔒 - The
DPoPcredential scheme’s raw pieces, straight off theAuthorizationandDPoPheaders — deliberately not routed throughpresent/Presented, which still foldsDPoPintoPresented::Disabledfor every other route in this module that has not opted into accepting it (seeDISABLED_SCHEMES’s own doc). Kept to exactly the two headers a caller presenting this scheme sends, mirroringpresent’s own header-splitting rather than growing that function a third scheme every existing call site would need a new match arm for. - expired_
token 🔒 ExpiredToken: the right scheme, a token that was once good.- insufficient_
scope InsufficientScope: a real token, asked to do what it was not granted. RFC 6750’sinsufficient_scope, in the DPoP scheme the token was presented under, naming the scope the write needed. Written to the journal with the client anddid, the account the token speaks for.- invalid_
dpop 🔒 InvalidTokenfor aDPoPcredential, challenged in theDPoPscheme, as RFC 9449 §7.1 asks. atproto clients refresh only on aDPoPchallenge naminginvalid_token; aBearerone is read as final.- invalid_
token 🔒 InvalidToken: the right scheme, a token this server does not recognise.- own_
account 🔒 require_self, without writing a refusal down.- present 🔒
- refused_
proof 🔒 - A refused
DPoPproof: RFC 9449 §9’suse_dpop_noncechallenge, with the nonce to retry with inDPoP-Nonce, for a proof that carries no nonce this server accepts, andinvalid_dpopfor any other. - refused_
xrpc 🔒 - Writes an XRPC credential refusal to the journal; see
crate::refusal. - require_
agent_ token - Resolves an agent token from the
Authorizationheader, forcom.atproto.repo.*’s write routes anduploadBlob. - require_
agent_ token_ or_ dpop - Resolves either an agent’s own
Credential::AccountTokenor a DPoP-bound OAuth access token minted bycrate::oauth::token, forcom.atproto.repo.*’s write routes anduploadBlob— the resource path an app authorized over OAuth actually writes through. - require_
disclosure - Resolves a
Credential::Disclosureroute: open unless this deployment’s configuration has closed it, in which case it is closed to everyone. - require_
self - Resolves an agent’s own credential against the account a request names, for every account-lifecycle mutation this server serves.
- route_
credentials 🔒 - scheme_
disabled 🔒 SchemeDisabled: the scheme is one this server recognises, and this route does not accept it. The message names the routes that do.