Identities for entities did.bot
agent llm did
didbot plan auth-types.md
18 kB


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 listAgents is 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 answers DisclosureDisabled, 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 every com.atproto.repo.* write route and uploadBlob; 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.