Dev.to · 8 min read

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

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: A PF token that PF-protected resources accept, and that carries the delegation chain. 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= &actor_token_type=urn:ietf:params:oauth:token-type:jwt &client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer &client_assertion= &resource=https://graph.microsoft.com &scope=User.Read 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 Read it as three checks the broker makes, in order: 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. 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. 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" } 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" } 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' 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.

This is a summary aggregated from Dev.to. Read the complete article on the original site:

Read full article at Dev.to

More AI & Machine Learning News