--- 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 This server accepts more than one kind of credential, and the question this epic answers is which kinds may exist, on what argument, and what a route does when a request carries none. It is deliberately not a policy engine and deliberately not a taxonomy written down twice. ## Where the set lives `Credential` in `crates/didbot-serve/src/auth.rs` is the set, and there is no copy of it here or anywhere else. A list of credential kinds kept beside the code is a list that goes stale silently; the enum's variants carry their own documentation, `routes.rs`'s `xrpc_methods!` invocations carry which route takes which, and both are compiled. Adding a kind, or changing what a route accepts, is an edit to one of those two places and to nothing else. What this epic decides, which no enum can say: **Which questions a credential kind exists to answer.** [oauth](oauth.md) builds one credential and the server that issues it. This epic is the other question: what this server does about every caller that is not holding one. A server implementing only OAuth still has to answer the client that arrives with an app password, and it has to answer in a way that says so — hence a named-but-disabled scheme answering "recognised, not accepted" rather than a bare 401, which is a decision about who may talk to this server rather than a detail. **That a kind is added only when no existing kind fits the caller.** The agent token was the worked example and is now the counter-example. It was added because a session token meant a machine holding a human credential shape — but what made that shape human was the *password*, not the session. Replace the password with the node attestation [node](node.md) already establishes and the rest of the session fits exactly: rotation, expiry, a refresh family, and reuse detection that revokes the family on replay. All of that is written and none of it exists on the bespoke token. - [ ] **Take a session by attestation, and retire the agent token.** One new way to authenticate `createSession`, and three gaps close with it: a year-long expiry, one live token per account, and no rotation route at all. Bind what the claim is for, not merely who is making it — a claim that names no subject is replayable against another account, which is the lesson the instance-identity work already learned about audience. - [ ] **Provisioning authenticates nobody today.** The request carries no claim field and the attested credential kind is a destination in the enum rather than a check. Requiring the node claim is the single largest hole in this taxonomy, and it sits on the route that gets hotter the moment contexts are minted lazily. **That there is no operator credential, and that this is load-bearing.** A server learns which DID operates it by reading `bot.did.operator` out of the operator's own repository — see [handshake](handshake.md) — a mechanism whose whole point is that no operator credential ever reaches a PDS. So a surface that would need one is not built rather than gated on a stand-in, account-lifecycle mutations are self-service or nothing, and a closed disclosure route is closed to everyone alike. Proving control of the operator DID *to* this server is [oauth](oauth.md)'s owner sign-in. **That the hook-stamped local identity in [credentials](credentials.md) is not a credential here and must not become one.** It says which agent is making a tool call. It is not presented over the wire and it authenticates nothing to this server. - [ ] **Inter-service auth, before something else grows into its place.** Not built. `Presented::Disabled("ServiceAuth")` reserves the scheme name so a caller presenting one is told "recognised, not accepted" — see [index](index.md)'s note that the first non-public read is when this becomes load-bearing, and [abuse-notices](abuse-notices.md) for a caller that is a stranger's service rather than our own. Whenever it is built, the shape is Bluesky's proposals [0013](https://github.com/bluesky-social/proposals/tree/main/0013-service-auth-refs) and [0014](https://github.com/bluesky-social/proposals/tree/main/0014-service-auth-revised): an `aud` naming a service on a DID rather than the bare DID, and `lxm` required rather than optional. - [ ] **Per-principal rate limits.** Not built. - [ ] **The caller-facing app-password lexicon methods.** `createSession` works; `com.atproto.server.{create,list,revoke}AppPassword` do not exist, so an operator sets the one password this server tracks per account directly rather than a caller managing several. ### 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 credential kind that did not exist yet, because none of the ones that did fit the caller: a harness client writes an agent's records 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 a machine 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. A client carries it the same way it already carries the DID, and the conformance suite proves the whole path: client and server agreeing on a credential none of them minted by hand. This is deliberately not `LegacySession`, not OAuth, and not inter-service auth, 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 describes where `provisionAgent` is going, not where it is: `ProvisionRequest` carries no attestation claim, nothing in the registry checks one, and every account is minted `Assurance::SelfAsserted`, so the route authenticates nobody. `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 — and so the one route that is open *and should not stay that way* is told apart from the routes that are open on purpose. See `crates/didbot-serve/src/auth.rs`'s module documentation for the full taxonomy this section only summarises, and that variant's own doc for what it enforces today, which is nothing. **Account-lifecycle mutations are always credentialed, never toggleable.** `Credential::AgentSelf`: the acting agent's own write credential, checked against the account the request names. A harness client 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](agent-accounts.md). 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 a few routes **The account-admin reads are disclosure, not mutation** — they say which agents a deployment runs and what happened to them — and the decision for them 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 those 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](ownership.md) 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](spaces.md) 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. ### What a deletion does to a credential A credential that outlives the account it authenticates is a credential with nothing behind it. `Registry::delete`, `Registry::hard_delete` and `Registry::soft_delete` therefore end every one of them, through one call: the registry revokes the agent token it holds and then calls the hook `Registry::set_account_revoke_hook` registers, which `didbot_serve::app_with_repos` wires to the stores that live beside the router — the account's legacy sessions, its app password, its OAuth grants and its unredeemed authorization codes. The hook is the shape `Estop::set_revoke_hook` already uses, and for the same reason: the type that knows an account is going has no way to reach the stores that authenticate it, and a registry that held them would be holding the router's state. Wired in `app_with_repos` rather than left to whoever built an `AuthState`, so every router this crate builds has it. **Soft delete and hard delete do the same thing here, and a freeze does not.** There is no path from `soft-deleted` back to `active` — only a hard delete moves an account on from it — so a credential left alive there would authenticate an account that can never write again, and every read a soft-deleted account still serves is public and needs no credential at all. A freeze is the reversible state: an account that will be unfrozen keeps what it holds, or lifting the freeze would mean re-provisioning it. ## Done - [x] **Take the account off the header.** `uploadBlob` no longer reads `REPO_HEADER` — deleted along with the header itself. The repository an upload lands in comes solely from the agent token `auth::require_agent_token` resolves. - [x] **Require an agent's own credential on the repo write surface itself.** `createRecord`, `putRecord`, `deleteRecord`, `applyWrites` and `uploadBlob` are `Credential::AgentToken`; see "The write surface, and the credential that unlocked it" above for what that credential is and why it is not `LegacySession`. - [x] **Decide the set, then refuse everything else by name.** The set is `Credential` in `crates/didbot-serve/src/auth.rs`, and it is not restated here. A scheme this server does not define and a scheme it names but does not accept are told apart on the wire: `Presented::Unrecognized` is `AuthenticationRequired` (401), `Presented::Disabled` is `SchemeDisabled` (403), a client-visible difference and not only an internal one. - [x] **One extractor, and a per-route declaration of what it accepts.** `routes.rs`'s `xrpc_methods!` macro is that declaration: it builds the router, the NSID lists and `auth::ROUTE_CREDENTIALS` out of one set of entries, and an entry that names no credential does not parse. So a route without one is a compile error rather than a route that defaults open, and a declaration cannot outlive the route it names, because deleting the entry deletes both. `every_served_route_has_a_credential` is what stops somebody mounting an XRPC route outside the macro. - [x] **Answer the app-password question.** This server mints them: an operator sets one through `didbot_pds::SessionAuth::passwords`, hashed with Argon2id (`AppPasswordHash`) and never stored reversibly. `createSession` refuses a wrong identifier and a wrong password identically, so a caller cannot use it to enumerate accounts. Not built: `com.atproto.server.{create,list,revoke}AppPassword` as caller-facing methods — an operator sets the one password this server tracks per account directly, a smaller surface than the full lexicon set. - [x] **Say what an operator credential is: there is not one.** No scheme this server accepts authenticates an operator, and none should. A server learns which DID operates it by reading `bot.did.operator` out of the operator's own repository — see [handshake](handshake.md) — a mechanism whose whole point is that no operator credential ever reaches a PDS. A header this server could compare would be one it holds, which is the thing the handshake makes unnecessary. The declarations carry the consequences: every account-lifecycle mutation is self-service (`auth::require_self`, an agent token naming the account the request names), a closed disclosure route is closed to everyone, and [e-stop](e-stop.md)'s admin socket rests on its own `0700`/`0600` permissions rather than on anything presented over it. Proving control of the operator DID *to* this server is [oauth](oauth.md)'s owner sign-in; until it exists, a surface that would need one is not built rather than gated on a stand-in. - [x] **State what stays open to everyone.** Decided and written down in `auth.rs`'s module doc, under "Why every public route is public": three separate reasons, not one — protocol-required (`com.atproto.sync.*` and `com.atproto.repo.*`'s reads, so a relay indexes these agents), structurally required (`/.well-known/did.json`, `/.well-known/atproto-did`, `createSession` — a DID or a credential that needs a credential to reach is incoherent), and public-by-default-and-toggleable (`Credential::Disclosure`, see "Publish by default" above) for the `bot.did.*` reads that used to be public by accident. The write surface and the `bot.did.*` mutations are not open — see above. - [x] **Credential the account-admin mutations.** They are `Credential::AgentSelf` — see "The account-admin surface, and the mistake `Credential::Public` let happen" above. `provisionAgent` is relabelled `Credential::Attested` so its attestation gate is visible in the table rather than shared with a label that meant "no credential" everywhere else. - [x] **Decide the account-admin reads, and build the toggle.** They are `Credential::Disclosure` — public by default, closeable per route by `auth::Disclosure`, and a closed route answering `DisclosureDisabled` — for every caller alike, since no credential reaches past the toggle — rather than a 404 or an empty list. See "Publish by default" above for why the default is public and why the toggle exists anyway. - [x] **The failure shapes, and the headers that go with them.** `AuthenticationRequired`, `ExpiredToken`, `InvalidToken` and the new `SchemeDisabled` are in `crates/didbot-serve/src/error.rs` and `auth.rs`, each carrying `WWW-Authenticate: Bearer` where a client would act on it. `DPoP-Nonce` is not emitted: nothing here issues DPoP tokens yet. - [x] **A route that needs a principal and has none is a bug the tests can see.** `crates/didbot-serve/src/tests.rs` and `auth.rs`'s own suite cover the no-credential case for `refreshSession`, `deleteSession`, `getSession` and every `com.atproto.repo.*` write route plus `uploadBlob`; `crates/didbot/tests/conformance/wire.rs` drives the same case, plus the cross-account `RepoMismatch` case, through the real router and the vendored lexicon documents. Left open, on purpose: inter-service auth, per-principal rate limits, and the caller-facing app-password lexicon methods.