Skip to main content

Module auth

Module auth 

Source
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 which Credential::Disclosure now replaces.
  • Credential::AccountToken — com.atproto.repo.*’s write routes, uploadBlob, getServiceAuth and getSession. Minted once, at provisioning, bound to one DID; see didbot_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 — see crate::error::ApiError::repo_mismatch — so a token from one account can never write another’s repository.
  • Credential::Creator — createAccount alone. The bearer credential names the account creating, in one of three shapes crate::tree::prove resolves: 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 #oidc entry 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 .localhost development stack, by crate::AuthState::unauthenticated_creates, and it creates beneath the deployment’s own account.
  • Credential::Proof — createSession alone: the two shapes above that are not a session, naming a hosted account.
  • Credential::KeyProofOrOperator — updateCredentials alone: a JWT one of the account’s keys signed, or the operator session. See crate::tree::update_credentials.
  • Credential::Disclosure — listAccounts, listAccountLedgers, getAccountLedger and stats: disclosure, public by default and narrowable per route by this deployment’s configuration, resolved by require_disclosure. See Disclosure for 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::Disclosure route that this deployment has closed is closed to everyone. There is no credential that still reaches past the toggle, so false means 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 of com.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 as Credential::Public on purpose — plan/spaces.md names “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.json and /.well-known/atproto-did resolve 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.resolveHandle is the same resolution, asked of this server by a client that cannot do DNS.
  • Public by default, on purpose, and narrowable. Credential::Disclosure — see Disclosure — which is what bot.did.listAccounts, listAccountLedgers, getAccountLedger and stats now 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.pollOperatorClaim alone. 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; see crate::operator_poll::MIN_POLL_INTERVAL.

Structs§

Authorized
A write Writer::authorize found inside the credential’s scope.
Disclosure
Per-route configuration for every Credential::Disclosure route: open (public) or closed (operator-only), one flag per route rather than one flag for all of them.
ScopeMissing
The one thing a token’s scope did not cover, which insufficient_scope names.
Writer
Who a repo write speaks for, as require_agent_token_or_dpop resolved it.

Enums§

Credential
A credential kind a route may declare it accepts.
DpopRefusal 🔒
What a refused DPoP credential was refused over, as RFC 9449 §7.1’s error names it.
Presented 🔒
What a request’s Authorization header actually named.
Wanted
What a request would have done, for the one it was refused over.

Constants§

DISABLED_SCHEMES 🔒
Schemes present names and refuses with 403 SchemeDisabled, so a client presenting one is told “not enabled” rather than “not a scheme”.
ROUTE_CREDENTIALS
Every com.atproto.*, app.bsky.* and bot.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_CREDENTIALS borrows.
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 Authorization header 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 a Credential::Disclosure route by configuration, so it is closed to everyone.
dpop_bearer 🔒
The DPoP credential scheme’s raw pieces, straight off the Authorization and DPoP headers — deliberately not routed through present/Presented, which still folds DPoP into Presented::Disabled for every other route in this module that has not opted into accepting it (see DISABLED_SCHEMES’s own doc). Kept to exactly the two headers a caller presenting this scheme sends, mirroring present’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’s insufficient_scope, in the DPoP scheme the token was presented under, naming the scope the write needed. Written to the journal with the client and did, the account the token speaks for.
invalid_dpop 🔒
InvalidToken for a DPoP credential, challenged in the DPoP scheme, as RFC 9449 §7.1 asks. atproto clients refresh only on a DPoP challenge naming invalid_token; a Bearer one 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 DPoP proof: RFC 9449 §9’s use_dpop_nonce challenge, with the nonce to retry with in DPoP-Nonce, for a proof that carries no nonce this server accepts, and invalid_dpop for any other.
refused_xrpc 🔒
Writes an XRPC credential refusal to the journal; see crate::refusal.
require_agent_token
Resolves an agent token from the Authorization header, for com.atproto.repo.*’s write routes and uploadBlob.
require_agent_token_or_dpop
Resolves either an agent’s own Credential::AccountToken or a DPoP-bound OAuth access token minted by crate::oauth::token, for com.atproto.repo.*’s write routes and uploadBlob — the resource path an app authorized over OAuth actually writes through.
require_disclosure
Resolves a Credential::Disclosure route: 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.