id: oauth title: A third-party app signs in as an agent, with nobody at the consent screen status: open crates: [didbot-serve, didbot-pds, didbot-agentd] dependsOn: [pds-writes] exitCriterion: > An atproto client that has never heard of this project completes an OAuth flow against an agent and writes a record with the token. #
oauth #
The hard part is who consents. OAuth's authorize step assumes a human at a browser. An agent has none, and is provisioned and collected inside one session. The answer is that the pushed authorization request itself is the question: at PAR this server resolves the account, works out what it would grant, and writes a decision record the named account reads and answers.
The decision is made once, at PAR, from two inputs.
policy's tree supplies the denials, asked about a
didbot_policy::Subject::Grant — including the client refusal
app-allowlist is about, which is a denyClient policy in
that tree and not a check of its own. scope-policy's
ceiling supplies the intersection. Where both are authored from is
policy-store's.
The record is never judged again. GET /oauth/authorize and
bot.did.approveAuthorization read it. Judging again at approve could only
contradict a record that already told the agent allow. Issuing a token is
a separate request, and POST /oauth/token judges the client again, within
the account's chain, for a code exchange and a refresh alike. A policy
loaded between the push and the answer leaves the record alone and refuses
the token. The scope ceiling is asked again the same way, at the token
endpoint and at every write: a login keeps what the record granted, and a
tightened ceiling narrows it further (scope-policy).
One rule the tree cannot carry here: didbot_policy::Subject::Grant names a
client_id and the requested scopes, and no account, so a per-account rule
is not expressible as a policy. Adding that field is the precondition for
retiring the ceiling; until then the ceiling is the deployment's own
statement and applies to every agent alike.
How an ordinary client signs in as an agent #
A pushed authorization request naming an agent becomes a decision record at the server, and the one-time consent reference minted with it is the approval token. The daemon relays the record and carries the answer back; nothing here needs a browser, and the agent is never handed a URL.
Two tool calls, and no browser anywhere:
- The agent starts an ordinary client in the background. It binds its loopback listener, pushes an authorization request naming its own DID as the account, and prints the authorize URL instead of opening it.
- The pushed request becomes a decision record at the server. The daemon is already long-polling for records about the accounts it issued, so it collects that one and puts it in front of the agent on its next tool call.
- The agent runs
didbot-oauth approve <token>ordidbot-oauth decline <token>. The daemon redeems the token as the account the record names, and fetches the redirect the server built — which is what delivers the code. - The client exchanges it and holds tokens bound to its own key.
An agent that does hold a URL for a request neither path delivered can name it
directly — didbot-oauth show <url>, and approve --url — which is the same
authenticated getAuthorization fetch, asked for rather than noticed.
On the ordinary path the agent never handles a URL, and on neither path does
it name its account. didbot-oauth is the only agent-facing command, and
there is no --as <did> in it: the daemon reads the account off the record,
whether the agent named that record by token or by URL. The didbot confirm
command that used to take one, and the confirm ask on the socket it sent, are
both gone.
Done #
-
`login_hint` is resolved at PAR (`crate::routes::resolve_identifier`), and no status or body this endpoint answers with may depend on whether the hint names an account — timing is a smoke-tested best effort, not a claim this makes (see the next item's own note on that): a hint naming nobody gets the identical `201` a real account's push gets, and a real account whose own pending bound is already full also gets that `201` rather than the `403` an account-keyed bound would otherwise answer with — see the next item for why *that* is load-bearing, not incidental. Proved end to end, with no browser and no network, by `crates/didbot-serve/tests/ oauth_agent_flow.rs`, which drives the real router through `tower::ServiceExt::oneshot`. -
Each check has its own unit test in `oauth::consent`, and `oauth_agent_flow.rs` drives both to a refusal over the real router — replay and an approval by the wrong account — confirming no code is issued on either. -
The same server-side-only checking holds one endpoint earlier, at `POST /oauth/par`, which is unauthenticated rather than under-credentialed: whether an account exists is a fact this deployment checks for itself, never one an unauthenticated caller may read back. Two things had to be true for that to hold, and neither did at first. The first is the status itself. On a deployment with nothing denied — every deployment this project ships, until an operator loads a policy — an unresolvable `login_hint` used to answer `400 invalid_request` naming the account as the reason, while every account that *does* exist answers `201`, whatever the ceiling and the gate go on to decide for it (a grant, a narrowing, or a `deny` verdict on a decision record that still mints no token — `plan/app-allowlist.md`'s "a denied client is refused at the push itself" is the one *exception* to this, since `didbot_policy::Subject::Grant` names no account and so answers the same way for every `login_hint` including a nonexistent one, which is what keeps it from reopening the same hole). `201` vs `400` was the whole oracle: push one request per candidate handle or DID and read account existence back from the status, reachable without ever calling `bot.did.listAgents` and so unaffected by narrowing that route's `Credential::Disclosure`. The fix is `oauth::par:: push_for_unresolved_hint`: a `login_hint` naming nobody now gets the identical `201`, `request_uri` and `expires_in` a real account's push gets, and nothing is stored under it that `GET /oauth/authorize` or `bot.did.getAuthorization` could ever redeem — the former because the `request_uri` never reaches `ParStore`, so it answers exactly as an expired or already-used real one does (`oauth::authorize`'s `an_expired_pushed_request_and_one_that_never_existed_answer_alike` checks the two are the same answer, not just similarly worded); the latter because a decision record is addressed to an account and no credential could ever authenticate as a string that resolved to none. The second is the bound, and a first attempt at it reopened the first fix by a different door. `pending_per_account` is an oracle of its own if a real account's `(per_account + 1)`th concurrent push answers `403` and a `login_hint` naming nobody never could: an attacker only has to push one candidate enough times to watch for the `403` a loaded, existing account would eventually answer with. A version of `push_for_unresolved_hint` closed *that* by putting a `DecisionRecord` through `DecisionStore::put` addressed to the raw `login_hint` string — but `resolve_identifier` folds a handle, its DID, and one written with a redundant `:port`/`%3Aport` into the single DID that names a real account, so a real account's bucket is one bucket however it is spelled, while every distinct spelling of a nonexistent one got its own. Push eight variously-spelled requests naming one candidate and a ninth: a real account's ninth bucket-fills and a made-up one's never does, whatever either bucket's *refusal* is worded — the aliasing itself is the oracle. It was also a new denial-of-service surface past the account-existence one: nothing bounds how many distinct invented strings exist, so a handful of addresses at the per-address rate limit could keep `pending_total` permanently full with entries no real account ever asked for, where before a stranger could not occupy a slot without naming one. The fix an account-keyed bound cannot have is not to bound unresolved hints by account at all: `push_for_unresolved_hint` stores nothing — no `ParStore` entry, no `DecisionRecord` — reusing only `ParStore::mint` for the `request_uri` it hands back. And a real account past its own bound is answered the same way, *not* `403`: the push lands, and the account's *oldest* undecided record makes way for it. `login_hint` names whoever the caller likes, so a bound that dropped the newest push let a stranger keep an account from signing in to anything by filling its slots every two minutes; displacing the oldest keeps the bound and keeps an account's freshest attempt — the one an agent is waiting on — always answerable. Per-account fairness still holds exactly as it did before — an account loses only its own excess, and no other account's bound is touched — logged at `info`, sampled one in a hundred against a process-wide counter rather than every occurrence, since the account is already known to be at its bound and logging every push against it would let the log line itself be flooded from as many addresses as a caller likes. An operator still sees a flood happening even though the caller cannot tell it from an ordinary push. The one `403` this endpoint answers with is `DecisionRefused::TooMany` — the *server's* bound, a fact about load that is true identically for every caller, real or not, checked for an unresolved hint through `DecisionStore::check_room` without writing anything. Undoing a record means undoing *both* stores it touched, not just `ParStore`: `consent::begin` mints a `ConsentReference` before `DecisionStore::put` is even asked, since a reference has to name the record it approves, and a version of this fix that only took the `request_uri` back out of `ParStore` left that reference's `PendingConsent` behind in `MemoryConsentStore` — which had no way to remove an entry short of it being presented, and nothing ever presents one for a record that no longer exists. On the displacement path that is a live entry a stranger could leak, unauthenticated, once per push, against any real account it names enough to reach its bound — the exact kind of unbounded growth this whole fix exists to close, just moved into a different store. `ConsentStore::discard` removes the reference on both undo branches now, and `MemoryConsentStore::mint` sweeps expired, unpresented entries the same way `MemoryDecisionStore::put` already sweeps its own map, since before this it was the one store in the module an abandoned entry outlived forever. The honest signal an operator or an admitted agent needs is unaffected: `bot.did.listAgents` still answers when disclosure is public, and a request naming a real account under its own bound is still recorded exactly as before, narrowing included, whatever the gate answers. `oauth_agent_flow.rs`'s `eight_spellings_of_a_real_account_and_a_ninth_unresolvable_hint_all_answer_201`, `the_nth_plus_one_push_for_a_real_account_lands_and_displaces_the_oldest`, `a_stranger_filling_an_accounts_pending_bound_cannot_lock_it_out`, `many_invented_hints_never_fill_the_servers_own_bound` and `a_full_store_refuses_a_real_account_and_an_unresolvable_hint_with_one_body` hold these over the real router; `oauth::par`'s own `a_displacing_push_leaves_the_consent_store_at_its_previous_size` and `oauth::consent`'s `an_unpresented_reference_is_swept_once_it_has_expired` hold the two halves of the consent-store fix; `an_unresolvable_hint_costs_about_the_same_as_a_real_push` is a smoke test on the clock, not a guard, and says in its own doc that the two paths do not do the same work — only that the gap is nowhere near large enough to time. -
- `GET bot.did.listPendingAuthorizations?cursor&wait` long-polls this account's live decisions, holding the request open up to 30 seconds or until this process is asked to stop; answers `{cursor, pending: [record]}`. The cursor names the store's epoch as well as a place in it, so one held across a restart lists from the beginning rather than stranding its holder on a sequence the new store will not reach. - `GET bot.did.getAuthorization?requestUri` answers one record. - `POST bot.did.approveAuthorization {token}` issues the code for the whole request and answers `{requestUri, granted, redirect}`, where `granted` is what the ceiling grants of it now. - `POST bot.did.declineAuthorization {token, reason?}` answers `{requestUri}` and spends the token, so a "no" is recorded rather than left to look like an expiry. The client's own `client_name`, `client_uri` and `logo_uri` are kept with the record and never serialized — `app-allowlist`'s "record the client's own copy; do not show it". Driven end to end over the real router in `crates/didbot-serve/tests/oauth_agent_flow.rs`. -
**5 minutes** (`oauth::token::ACCESS_TTL`). This deployment has no revocation endpoint (see the next item) and no per-token introspection, so by the profile's own rule it does not qualify for the 30- or 15-minute allowance — those are for a server that can revoke an individual token out from under a client without waiting for it to expire. This one can't: a live access token is good until it expires, full stop, and the only lever this deployment has over an agent that should stop making requests is the e-stop (`EstopRefusal::Token`), which blocks *new* issuance and does nothing to a token already outstanding. That makes the lifetime the actual bound on how long a paused or de-admitted agent keeps working — not a compliance number, the number. 5 minutes means an operator's e-stop, or an `app-allowlist` de-admission, is fully in effect within 5 minutes of being thrown, refresh rotation notwithstanding (a family already revoked, or a client already refused at `token`, gets no new pair to wait out). The refresh token (`oauth::token::REFRESH_TTL`, 14 days) is long precisely because it is *not* the safety boundary — the access token is — and rotates on every use with reuse detection, the same design `didbot_pds::session::SessionAuth` already carries for legacy sessions.