DEV Community

DarkEdges
DarkEdges

Posted on

Letting AI agents act for users: the RFC 8693 token exchange

AI agents are getting access to the same systems people use: mail, calendars, files, directories. That raises an awkward
question. When an agent calls Microsoft Graph, whose access is it? If the answer is "the user's", the agent can do anything
the user can do. If the answer is "the agent's own service account", the audit log stops saying who asked for what.

This series describes how we handled that in ping-entra-broker, a token broker that sits between PingFederate (PF) and
Microsoft Entra ID. The broker issues an agent a token that names both the user it acts for and the agent doing the work,
and it limits that token to the one resource the agent asked for. This first part covers the front door: the exchange
request, the resource map that decides what is allowed, and the two ways the result can be used.

The problem in one request

A user signs in to PingFederate. PingFederate federates with Entra over OIDC, so the user's identity lives in Entra. When an
agent then needs to reach a downstream API, it needs two things:

  1. A PF token that PF-protected resources accept, and that carries the delegation chain.
  2. An Entra token with the right audience and scopes for the resource it is calling, such as Graph.

Two rules shaped the design. The agent never builds Entra scope strings, because that is an Entra detail that changes and
that agents get wrong. And the agent never holds the user's Entra refresh token. The broker keeps the refresh token and does
the exchange on the agent's behalf.

Why token exchange

We chose RFC 8693 token exchange as the entry point. It is a standard
request with a clear shape: a subject token (whose access this is), an actor token (who is acting), and a target. The target
is named with resource, which RFC 8707 defines for exactly this use.

The request looks like this:

POST /broker/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<the user's PF access token>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&actor_token=<the agent's signed client assertion>
&actor_token_type=urn:ietf:params:oauth:token-type:jwt
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<the same signed assertion>
&resource=https://graph.microsoft.com
&scope=User.Read
Enter fullscreen mode Exit fullscreen mode

Each part does a job:

Field Who supplies it What the broker checks
subject_token the agent, from the user's session a valid PF access token, so the broker knows whose access this is
actor_token the agent a JWT signed by the agent's registered key, with iss and sub set to the agent's client id
client_assertion the agent the same assertion, used to authenticate the agent to the broker
resource the agent must be in the resource map, and allowed for this agent
scope the agent a subset of the scopes the resource map lists for the agent

Two details matter. The broker verifies the assertion's audience, so one captured assertion cannot be used at another
service. And it records each assertion's jti in its store, so a replayed assertion is refused. We found the replay check
is what stops a captured assertion from being redeemed twice.

The resource map decides what is allowed

The agent asks for a resource by its URI. The broker never trusts that URI on its own. It looks the URI up in a versioned
resource map, which is a YAML file that records what each resource is, which tenant it lives in, which Entra audience and
scopes it needs, and how it may be delivered.

Here is a trimmed version of the sample map shipped with the repository:

version: 1
resources:
  - resource: https://api.contoso.com/orders
    type: api
    tenant: 11111111-1111-1111-1111-111111111111
    entra_audience: api://orders-api
    scopes: [orders.read, orders.write]
    delivery: { default: proxy, allow_token_mode: false }
    max_hops: 2
    method_scopes: { GET: orders.read, PATCH: orders.write, DELETE: orders.write }
    rebac:
      required: true
      object_from: path
      object_type: order
      relations: { GET: viewer, PATCH: editor, DELETE: owner }

  - resource: https://graph.microsoft.com
    type: api
    tenant: home
    entra_audience: https://graph.microsoft.com
    scopes: [User.Read, Mail.Read]
    delivery: { default: token, allow_token_mode: true }
    max_hops: 1

agents:
  - pf_client_id: report-writer
    entra_agent_identity: { home: "00000000-0000-0000-0000-000000000002" }
    allow:
      https://api.contoso.com/orders: [orders.read]
      https://graph.microsoft.com: [User.Read]
    allow_token_mode: true
    max_hops: 2
Enter fullscreen mode Exit fullscreen mode

Read it as three checks the broker makes, in order:

  1. Is the resource known? https://api.contoso.com/orders is listed. A resource that is not listed is refused, so a typo cannot reach an unexpected audience.
  2. Is this agent allowed it? The agents section names what each agent may use. report-writer may read orders, and it may call Graph for User.Read. It may not write orders, because orders.write is not in its list. The broker takes the intersection of what was asked and what is allowed, so asking for more than allowed is refused rather than quietly narrowed.
  3. Is the delivery mode allowed? allow_token_mode decides whether the agent may receive the Entra token itself. For the orders API it is false, so the agent must use proxy mode there.

The max_hops value bounds agent chains. If agent A asks the broker for a token that agent B then uses to call the broker
again, each hop counts. The chain can never be longer than the smallest limit along it, and no hop can widen the rights of
the one before it.

Example: a read the broker allows

Suppose report-writer wants to read the user's profile from Graph in token mode. Its request names User.Read, which the
map allows for it, and Graph is marked as token mode. The broker performs the exchange and returns a response of this shape.
The values are illustrative and the token is shortened:

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "token_type": "Bearer",
  "expires_in": 3599,
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "scope": "User.Read",
  "delivery": "token"
}
Enter fullscreen mode Exit fullscreen mode

The agent can now call Graph directly with that token. The scope field shows the agent exactly what it received, which is
the narrow set, not everything the user can do.

Example: a write the broker refuses

Now suppose the same agent asks for orders.write on the orders API. The resource exists and the agent is known, but
orders.write is not in its allow list. The broker answers with an error in the RFC 6749 shape, and it writes an audit
event recording the decision:

{
  "error": "invalid_scope",
  "error_description": "scope orders.write is not allowed for this agent on this resource"
}
Enter fullscreen mode Exit fullscreen mode

The refusal is the point of the design. The agent learns what it may not do, and the audit log records that it tried.

Two delivery modes

Once the broker has an Entra token for the agent, it can deliver the result in one of two ways.

  • Token mode. The broker returns the Entra access token, as in the Graph example above. It is allow-listed per resource, because a bearer token in the agent's hands can be used anywhere until it expires.
  • Proxy mode. The broker keeps the Entra token and calls the API itself. The agent receives a proxy URL and sends its requests there. This is the default for the orders API, and the reason is the audit point: every call passes through one place where it can be logged, rate-limited and checked against the ReBAC rules in the map.

A proxy request from the agent looks like this:

curl -sS 'https://broker.example.com/broker/proxy/orders/orders/1001' \
  -H 'authorization: Bearer $PROXY_TOKEN'
Enter fullscreen mode Exit fullscreen mode

The $PROXY_TOKEN is the proxy-scoped token the broker issued in the exchange response, not the Entra token. The Entra token
never leaves the broker. In the demo console this curl view is generated for every step, with tokens replaced by names such
as $USER_TOKEN so the shape is visible but nothing is usable.

Why one front door matters

Without a single exchange point, every agent would hold its own copy of the Entra logic: scopes, audiences, refresh
handling and retries. Each copy would drift. Putting the logic behind one endpoint means a resource is added in one file,
the policy is checked in one place, and a bad scope is caught by the broker rather than by each agent.

The next part covers what happens behind the endpoint: how the broker turns the user's stored refresh token into a token for
the agent, and why that takes an agent identity and not just a client secret.

Top comments (1)

Collapse
 
kami_butt_69b922ade8de012 profile image
Agentic Affairs •

The 'agent never holds the user's Entra refresh token' rule is the load-bearing one — everything else is bookkeeping once the broker owns the exchange. Proxy mode as default for the orders API is the sharper choice too: a bearer token in an agent's hands works anywhere until expiry, while a proxy call passes through one auditable place. Where do you draw the token-mode line in practice — is it per-resource sensitivity, or do some agents earn token mode by track record?