id: auth-types title: Every credential this server accepts, and what each one may do status: open crates: [didbot-serve, didbot-pds] dependsOn: [agent-accounts, pds-writes] exitCriterion: > Every route the router serves names the credential types it accepts, a request carrying none is refused wherever the route needs a principal, and each accepted type is driven end to end by the conformance harness. #
auth-types #
Nothing authenticates. Every com.atproto.* route answers whoever reaches it,
and where a route needs to know which repository it is acting on it reads that
off a parameter or a header — uploadBlob takes the account from a header
precisely because there is no session to take it from. Read paths are
unauthenticated by accident rather than by decision, and no write path refuses
anybody.
oauth builds one credential and the server that issues it. This epic is the other question, which that one does not answer: which credential types exist here at all, which routes take which, and what a route does when a request carries none. A server that implements only OAuth still has to answer the client that arrives holding an app password, and it has to answer it in a way that says so.
The types #
OAuth access tokens, bound with DPoP. The atproto-native one and the only one a third-party app should ever get. Issuing it is oauth; accepting it is here — proof verification, nonce issuance, replay, and the thumbprint check that makes the token useless to anyone who copies it.
Legacy session tokens. com.atproto.server.createSession, refreshSession
and deleteSession, with an app password behind them. Most deployed atproto
software speaks this and nothing else, so refusing it is a decision about who
can talk to this server, not a detail.
Inter-service auth. The short-lived signed JWT from
com.atproto.server.getServiceAuth, naming an audience and a method. This is
how one service calls another as an account rather than on its own behalf, and
it is the credential this project's own components would use.
Operator auth. e-stop and anything under com.atproto.admin.*
need a caller the server trusts that is not an agent and is not an app.
The hook-stamped local identity in credentials is not on this list and must not end up on it. It says which agent is making a tool call. It is not presented over the wire and it authenticates nothing to this server.
The write surface, and the credential that unlocked it #
The write surface is no longer open. com.atproto.repo.createRecord,
putRecord, deleteRecord, applyWrites and uploadBlob all require
Credential::AgentToken now, and the authenticated DID is checked against
the repository the request names — ApiError::repo_mismatch (403
RepoMismatch) is what a token from one account gets for trying to write
another's, an invariant rather than a policy knob.
Getting there needed a fifth credential kind the original taxonomy above
does not name, because none of the four fit the caller: didbot-hookd
writes an agent's scrobbles and memories automatically, with no person at a
login screen and no operator minting an app password for it, so
LegacySession was the wrong vehicle — building it would have meant
didbot-hookd holding a human credential shape for a caller that is
never human. What it needed instead was the smallest thing
plan/credentials.md's framing already asks for: a bearer token,
minted once at provisioning, bound to one DID, with an expiry and no
rotation. That is didbot_pds::credential — Credential::AgentToken on
the HTTP side — and Provisioner::provision now issues one automatically,
durable in the write-ahead log (pds.layout bumped to 3), returned once as
agentToken in provisionAgent's response body. didbot-hookd carries it
the same way it already carries the DID — stamped onto every scrobble
alongside STAMP_KEY — and crates/didbot/tests/hook_to_record.rs proves
the whole path: hook, scrobble host and server agreeing on a credential
none of them minted by hand.
This is deliberately not LegacySession, not OAuth, and not inter-service
auth — a fifth entry in the taxonomy the module doc above names as "the
five kinds this server accepts" now, and getSession's own end-to-end
proof (below) still stands as LegacySession's.
The account-admin surface, and the mistake Credential::Public let happen #
The write surface above closed com.atproto.repo.*. It left bot.did.*
— this server's own account-admin surface — open, on the reasoning that
provisionAgent's credential is the ProvisionRequest's attestation claim,
checked inside the registry rather than at the HTTP layer. That reasoning is
sound for provisionAgent alone. deleteAgent, setAgentPinned and
setAgentScrobbling inherited Credential::Public alongside it without
inheriting the attestation check, which is a route that destroys an account
answering an unauthenticated POST — a single request, no session, no
enumeration even needed because listAgents handed out every DID for free.
provisionAgent is now its own credential kind, Credential::Attested, so
Public can no longer mean two different things depending on which route
you are reading; see crates/didbot-serve/src/auth.rs's module documentation for the full
taxonomy this section only summarises.
Mutations — deleteAgent, setAgentPinned, setAgentScrobbling — are
always credentialed, never toggleable. Credential::AgentSelf: the acting
agent's own write credential, checked against the account the request names.
didbot-hookd calls all three automatically — the same shape that argued
(wrongly) for Public before — but by the time one of these fires the
account already has its own write credential, and that is exactly the caller
these routes are reasonable to ask one from. An agent may end or toggle
itself, and that is the whole list: acting on another account would need a
credential proving this deployment's operator, and there is deliberately no
such credential. bot.did.hardDeleteAgent, which must not be self-service,
consequently has no route at all — see agent-accounts.
There is no netizen argument for letting a stranger delete an account, so
this stays hard-locked regardless of the disclosure decision below.
Publish by default: the accountability argument, and why it is a principle rather than a note about four routes #
listAgents, listAgentLedgers, getAgentLedger and stats are
disclosure, not mutation — reads that say which agents a deployment runs
and what happened to them — and the decision for these is not "lock them
like the mutations." It is Credential::Disclosure: public by default,
narrowable per route by deployment configuration, and never widenable past
public. auth::Disclosure carries one bool per route, every field
defaulting true; a deployment may flip a field to false, and nothing may
flip one back past true, because there is nothing wider than public to
widen into — the same narrowing-only rule that governs every configuration
tier below the owner's own repository.
The reasoning is not specific to these four routes, and should be read as one this project applies wherever a "should this be public" question of this shape comes up again — not re-derived per route:
- What is being published is the accountability mechanism, not an incidental leak. ownership exists so a stranger can ask whether an agent traces to a responsible human. That question has no answer if the deployment being asked is also the one deciding whether to be asked. Publishing which agents exist and what happened to them — the roster and the ledger — is the same argument this project already makes for publishing what it is doing elsewhere: divergence from what was promised has to be a comparison somebody outside can make, or the promise is unverifiable. A fresh deployment therefore starts open, not closed-until-configured.
- The toggle exists because "private means federation off" is a real, legitimate posture. spaces names it for the deployment as a whole; per-route disclosure is the same choice at a smaller grain, for an operator running agents on behalf of people who would rather not have every account enumerable by a stranger. Per route rather than one switch: closing the ledger while leaving the roster open (or the reverse) is a plausible posture a single flag cannot express.
- Closing a route has to be visible, or the mechanism defeats itself.
The threat this default resists is an operator who cannot be audited, and
the configuration tier that closes a route is a tier an on-box attacker
also reaches — closing
listAgentsis exactly what that attacker would do to hide what a stranger needed to see. This cannot be prevented from the server alone, but it is made legible: a closed route answersDisclosureDisabled, never a 404 and never an empty list, so an outsider can tell "this server runs no agents" from "this server is not telling you" — two different facts, and only one of them is suspicious. An operator credential still reaches a closed route, so closing it narrows who may read it without narrowing who may audit it.
This closes the "state what stays open to everyone" item below for
bot.did.*: public-by-accident is gone, and what remains public does so for
a reason the table now states, not because nobody got to it. It is written
as a decision for now, not a permanent property — the owner has said
this surface will be revisited, likely with finer-grained scoping than
per-route booleans, and plan/spaces.md's alternative posture is exactly
the direction that revisit would move in.
com.atproto.*'s read surface is a separate question this epic does not
answer. com.atproto.sync.* and the read half of com.atproto.repo.* are
public because the protocol requires it: a relay crawls them and an appview
indexes them, and a credential there means no relay indexes these agents and
nothing they write reaches Bluesky, contradicting what this project says it
does. Whether this deployment federates at all is the owner's decision, made
and recorded — Credential::Public on purpose, not an accident, and
plan/spaces.md's "private means federation off" is what a deployment
reaches for if it decides otherwise. /.well-known/did.json,
/.well-known/atproto-did and createSession are public for a third, purely
structural reason: a DID that needs a credential to resolve is not a DID,
and a credential requirement on the route that issues credentials is
circular. See crates/didbot-serve/src/auth.rs's "Why every public route is public" for all
three reasons laid out together.
Done #
- Legacy sessions:
com.atproto.server.{create,refresh,delete,get}Session, app passwords hashed with Argon2id, refresh-token rotation with family-wide revocation on reuse (didbot_pds::session). - Agent tokens:
didbot_pds::credential, minted at provisioning, durable in the write-ahead log, and required on everycom.atproto.repo.*write route anduploadBlob; see "The write surface, and the credential that unlocked it" above.
Left open, on purpose: inter-service auth, per-principal rate limits, and the caller-facing app-password lexicon methods.