--- 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](policy.md)'s tree supplies the denials, asked about a `didbot_policy::Subject::Grant` — including the client refusal [app-allowlist](app-allowlist.md) is about, which is a `denyClient` policy in that tree and not a check of its own. [scope-policy](scope-policy.md)'s ceiling supplies the intersection. Where both are authored from is [policy-store](policy-store.md)'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](scope-policy.md)). 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. - [ ] **Test against a client that is not ours.** Whether atproto client SDKs implement the `did:web` method at all is unknown, and it is the question the rest of this epic rests on: most atproto software has only ever resolved `did:plc`, and a handle-to-server path may assume a directory lookup. An agent nobody can sign into is a capability on paper. Transport is not the issue — `did:web` is defined over HTTPS and a resolver that only speaks HTTPS is conformant. A client using the profile's "Localhost Client Development" `client_id` (`http://localhost`, no port, its metadata carried in `redirect_uri`/`scope` query parameters) is accepted without a fetch — `oauth::client_metadata::resolve_client` recognises the form and builds the document from the query alone. ## 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: 1. 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. 2. 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. 3. The agent runs `didbot-oauth approve ` or `didbot-oauth decline `. The daemon redeems the token as the account the record names, and fetches the redirect the server built — which is what delivers the code. 4. 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 `, 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 ` 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. - [ ] **Two things a client must support, and no more.** Print the URL rather than opening it, and accept an account identifier to resolve. Neither requires it to have heard of this project. - [ ] **Replace the refuse-everything default with policy.** [policy](policy.md) decides which client is denied; absent a denial the agent approves what it asked for. ## Done - [x] **One seam, at PAR, in the record.** Two things judge a sign-in and both are read inside `oauth::par::push_authorization_request`: the `PolicyGate` over a `Subject::Grant`, and `ScopePolicy::ceiling`. Every route after it reads the decision record and evaluates nothing, so no refusal can contradict a record that already said `allow`. `oauth_agent_flow.rs`'s `a_policy_loaded_after_par_leaves_the_record_and_refuses_the_token` loads a denial between the push and the answer: the record's verdict is unchanged, `GET /oauth/authorize` still renders it, the approval still issues a code, the code exchanges for no token, and the *next* push is denied. - [x] **A token is judged when it is issued.** `POST /oauth/token` asks the same gate about a `Subject::Token` before a code exchange or a refresh mints anything (`oauth::token::GateAdmission`). A refusal is `invalid_grant` and ends nothing: the family keeps its refresh token, unrotated, until it expires or the policy lifts. A replayed refresh token still ends its family first. Tests: `oauth::token`'s `a_refused_refresh_keeps_its_family` and `a_replay_ends_its_family_even_under_a_refusal`, and `crates/didbot-serve/tests/denied_app.rs`. - [x] **The agent completes the flow itself.** The profile has no device grant and no client credentials grant: every flow assumes a browser. The agent needs none: `POST /oauth/par` mints the decision, and the account named in it reads and answers it over `bot.did.*`. A headless browser is still served — `GET /oauth/authorize` renders the same record — and is no longer on the path. `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`. - [x] **Confirm after the page, through an identity-aware call.** `bot.did.approveAuthorization` takes the decision's one-time token under `Credential::AgentSelf`, so the acting account is the one the presented agent token authenticates as and never a value in the body. Two checks: the reference is live and unused, and the account named in the request is the account approving — the second enforced by the credential. There is no third. A record whose verdict is `deny` mints no reference, so whether policy allows this is answered in the record before anything reaches here. It is the only way an authorization is approved: there is no unauthenticated route that takes an acting DID as a field in a body. 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. - [x] **The account is checked server-side, not by the daemon.** There is nowhere in `bot.did.approveAuthorization`'s request to name an account: the decision it spends is looked up by its token and answered as unknown unless it is addressed to the account the credential authenticates as. A daemon relaying a token it should not hold learns nothing from the refusal. 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. - [x] **A pushed request is a decision record.** `crates/didbot-serve/src/oauth/decision.rs`. `POST /oauth/par` resolves `login_hint` through the registry, narrows the requested scope to `ScopePolicy::ceiling`, runs a `didbot_policy::Subject::Grant` past the `PolicyGate`, and stores a `DecisionRecord`: the client's origin and content key, whether this account has seen that key before, what was asked, the verdict (`allow`, `narrow` naming what it cut and the rule that cut it, or `deny` naming the reason and the rule), and the one-time token that approves it. An approval covers the whole request, so a `narrow` cut is withheld for now, not refused: `didbot-oauth pending` ends a narrowed line with `approves=asked ceiling-checked=each-use`, and the consent page and the approve answer list the scopes requested, then the scopes current policies would allow. A `deny` mints no token. A request naming one of [scope-policy](scope-policy.md)'s hard-blocked capabilities is denied before the ceiling is consulted at all. Every narrowing and every denial writes a payload-free row to the evaluation log. Records live as long as their pushed request, are swept when it expires, and are bounded per account and in total, because PAR is unauthenticated — `[oauth] pending_per_account` and `pending_total`, beside `scope_ceiling`, which is where an operator writes the ceiling down. Four routes, all `Credential::AgentSelf`, all naming no account: - `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`. - [x] **The daemon is the user-agent, and that is a request the model influenced.** Every fetch it makes on a request the model touched is bounded: the deprecated page path accepts only the authorize endpoint this deployment publishes in its own discovery document, the approval path fetches only a redirect the server built, and both deliver only to loopback (`didbot_agentd::loopback`), with redirects turned off on every client so there are no chains. The server cannot make that last fetch itself — the client is listening on the agent host and, by the architecture rule, the server is not on it. - [x] **The daemon relays, outbound only.** One long poll per account it issued (`didbot_agentd::poll`, `listPendingAuthorizations?wait=25`), backing off from a second to half a minute when the server is not answering, started when a context is provisioned and stopped when the harness says that context has ended. Nothing on the agent host listens for the server. Decisions ride the answer to an exchange the hook started, so an agent learns of one at its next tool call; that latency is accepted rather than hidden. `seen_request_uris` on a report is the fast path for a client that printed its URL in the tool call being reported, fetched with `getAuthorization` when no poll has delivered it yet. - [x] **The granular atproto scope grammar.** `crates/didbot-serve/src/oauth/scope.rs`: `Scope::parse`/`ScopeSet::parse` read the wire grammar directly, not a parallel one — `repo:`, `rpc:`, `blob:`, `identity:`, `account:`, `include:`, `atproto`, and the three `transition:*` legacy scopes. `Scope::contains` and `ScopeSet::intersect` are the two operations `scope-policy`'s ceiling check needs, and both are covered against the asymmetric cases a ceiling has to get right: a narrower action set against a broader one, `rpc:`'s `aud` narrowing, an empty ceiling, an empty request, a scope naming a collection outside the ceiling, and a ceiling whose atoms admit a ceiling-order-independent grant (the one intersect bug review already found here). An unrecognised `kind:` fails the whole scope string at parse time — see the module doc's "Unknown prefixes" section — so a client sending a prefix this server doesn't implement gets no grant at all, rather than a grant scoped to whatever this server understood. `crates/didbot-claim/src/scope.rs` is the first real consumer, building `didbot-claim`'s own request scope out of the grammar instead of a hand-written literal. - [x] **DPoP, verifier side.** `crates/didbot-serve/src/oauth/dpop.rs`: `DpopVerifier::verify` checks a proof's `typ`, `alg`, embedded `jwk` and signature, `htm`/`htu` against the request, `iat` against a named clock-skew window, and `jti` against a replay cache bounded by that same window; `jwk_thumbprint` gives RFC 7638 thumbprints standalone, for an access token's `cnf.jkt`. Nonce issuance and the `use_dpop_nonce` retry path are in the same file. The authorization server that calls it is still open, above. - [x] **The authorization server.** Pushed authorization requests, authorize, token, refresh rotation, client metadata fetching and validation. `crates/didbot-serve/src/oauth/{par,authorize,token,client_metadata}.rs`. Both grants are driven through the real router in `crates/didbot-serve/tests/oauth_agent_flow.rs`, which is where the rotation's wire behaviour lives: a client that spends a refresh token twice loses the family, and one that presents the right token under the wrong key is refused without losing it. The two things that judge a request are read at `push_authorization_request` and nowhere else: `plan/policy.md`'s gate over a `Subject::Grant`, and `plan/scope-policy.md`'s ceiling. With no denial loaded each permits, as [policy](policy.md)'s empty set does; see `oauth::authorize::GrantAnyScope`. - [x] **DPoP, wired end to end.** `OAuthState::default` builds the real verifier (`oauth::dpop::DpopVerifier`, via its `impl oauth::dpop_seam::DpopVerifier`), `/oauth/token` binds a proof's `htu` to this deployment's own zone service document rather than a header (`oauth::discovery::request_htu`), and `OAuthTokenStore::validate_access` requires the caller to supply a proven thumbprint, not a bare token string. `com.atproto.repo.*`'s write routes accept a DPoP-bound access token now, through `auth::require_agent_token_or_dpop`. See `plan/adversarial.md`'s DPoP-binding tests for the seam this closed. - [x] **Pick an access-token lifetime and write it down.** The profile says under 30 minutes, under 15 without individual revocation, 5 recommended. The number decides how fast pausing issuance drains a swarm. **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. - [x] **No revocation endpoint exists in the profile**, and none is needed: withdrawal is refusal at the write path, because this server is also the store. See [write-policy](write-policy.md). Confirmed by the access-token lifetime decision above, which treats the absence of one as a fact the lifetime has to account for rather than as an omission to fix. - [x] **Rate-limit the authorize path**, which does work for unauthenticated callers. A fixed-window limiter keyed on the caller's address (`crate::rate_limit`, shared with `createSession`'s limiters — one module, not a second copy under `oauth`), the same self-contained- primitive approach `didbot_pds::session` uses rather than a new dependency for one call site. Keyed on the real TCP peer address by default (`axum`'s `ConnectInfo`, wired at `serve_router`/`serve_tls`), not `X-Forwarded-For`/`X-Real-IP` — this deployment terminates TLS itself with no reverse proxy in front of it (`didbot-tls`, reached directly on 443), so trusting those headers unconditionally would let a caller mint a fresh budget on every request just by forging a new one. A deployment may opt into reading them instead via `AuthState::trust_forwarded_headers` (off by default, wired today as `didbot-pds --trust-forwarded-headers`), once a real reverse proxy sets them and strips any client-supplied copy. See `crate::rate_limit`'s own doc. - [x] **Server metadata**, and the DID document pointing at it, or discovery fails before any of the above runs. `GET /.well-known/oauth-authorization-server` (`oauth::discovery`) derives its `issuer` from the same service document `GET /.well-known/did.json` already serves for the zone host, rather than recomputing a hostname — so the two can never disagree, and nothing here hardcodes `pds.did.bot` or any other name.