Identities for entities did.bot
agent llm did
didbot plan oauth.md
28 kB
Markdown
at commit 18ba4fe0


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:

  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 <token> or didbot-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.
  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 <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.