Agent identity and delegated access

Status: draft 0.1 (agent-grant/0.1). Covers friction #3 (agents borrow the user's full session or password), #8 (sites can't tell which agent acts for whom), #9 (bot detection blocks legitimate agents) and #16 (OAuth scopes are coarse and long-lived). Reference implementation: agent_index/identity.py.

The problem

Today an agent either logs in as the person, with every permission the person has and no record that an agent did it, or it looks like an anonymous scraper and gets blocked. Sites can't tell a person's own agent from abuse, so they block both or trust both.

The model

Three keys, two signatures:

  1. The person has a key, registered with the site once (the way a passkey is).
  2. The agent has its own key. Its id is the public key, so anyone can verify it without a lookup.
  3. The person signs a grant: this agent, this site, these scopes, these limits, this purpose, until this time.
  4. The agent signs every request with its own key and attaches the grant.

The site checks both signatures and learns who is acting (the agent), for whom (the person), with what authority (scopes and limits), and why (purpose). It never sees the person's password or session.

Grant

A compact token: base64url JSON payload, a dot, base64url Ed25519 signature by the person's key.

{
  "typ": "agent-grant/0.1",
  "iss": "ed25519:<person public key>",
  "iss_name": "Maxam",
  "sub": "ed25519:<agent public key>",
  "aud": "shop.example",
  "scopes": ["orders:read"],
  "constraints": {},
  "purpose": "Check order status",
  "iat": 1794200000,
  "exp": 1794203600,
  "jti": "9c1e0f4b2a7d3e11"
}

Rules: - aud is one site. A grant for one site is useless on another. - exp defaults to one hour. Task-length grants are the point; long-lived ones defeat it. - scopes are the site's own names, published in its action manifest. - constraints carries limits the site enforces, such as spending caps (see payments). - The person can revoke a grant by jti at any time; sites keep a revocation list until exp.

Signed requests

Requests are signed with HTTP Message Signatures (RFC 9421), Ed25519, in the Web Bot Auth profile (agent_index/httpsig.py):

Agent-Grant: <grant token>
Content-Digest: sha-256=:<base64>:
Signature-Agent: "https://agents.example"
Signature-Input: sig1=("@method" "@authority" "@path" "@query" "content-digest" "agent-grant" "signature-agent");created=1794200123;expires=1794200183;keyid="<JWK thumbprint>";alg="ed25519";nonce="<random>";tag="web-bot-auth"
Signature: sig1=:<base64>:

Signed agents without a grant (#9)

An agent that only reads public pages doesn't need a grant, but it shouldn't look like an anonymous scraper either. With AGENT_INDEX_SIGNATURE_AGENT set to the URL where its operator hosts a key directory (agent-index key-directory prints it, to serve at /.well-known/http-message-signatures-directory), every request agent-index sends is signed with Web Bot Auth. Bot-management services that verify Web Bot Auth can then let it through. A site using the middleware passes bot_keys=httpsig.DirectoryResolver(fetch=...): it fetches and caches the directory, verifies the signature, and hands the app scope["signed_agent"]. The site's policy can require it with requires_signature.

Issuing, holding and revoking grants

What a site does with it

Errors

not_delegated, bad_token, bad_signature, bad_key, unknown_principal, unknown_agent, wrong_audience, grant_expired, grant_revoked, wrong_agent, stale_request, replayed_request, scope_missing. Each is a stable code, never prose (see errors).

Relation to existing work

Signatures are RFC 9421 in the Web Bot Auth profile, which bot-management services already verify. The grant could also be carried as an OAuth token-exchange result, so existing OAuth servers can issue grants; that is the remaining step for sites that already run OAuth.