Part 1 covered the front door: an agent sends an RFC 8693 exchange request and the broker checks it against a resource map.
This part covers what happens next. The broker has to produce an Entra token that Graph or another API will accept, for the
right user, acting through the right agent. That turns out to need more than a client secret, and this part explains why.
The pieces in Entra
Microsoft's Agent ID feature gives an AI agent its own identity in Entra. Three objects matter here.
-
An agent identity blueprint. An application object that describes a kind of agent: its name, what it may do, and which
Graph permissions its agents may inherit. Blueprints are the templates. The blueprint has its own certificate credential,
an identifier URI of the form
api://<appId>, and a delegated scope calledaccess_as_user. - Agent identities. Each is created from a blueprint and represents one running agent. An agent identity is a service principal, so it can be granted access and audited on its own.
- The user. The human whose access the agent uses. The user signs in once, and the broker keeps a refresh token for them.
The broker's own RP app (the registration that performs the user sign-in) is a separate multi-tenant application.
Its redirect URIs point at the broker, and it is pre-authorised to call the blueprint's access_as_user scope.
In the sample resource map from Part 1, report-writer names an entra_agent_identity with a home tenant and an object id.
That id is the agent identity the broker acts as when it exchanges tokens for that agent.
The three steps
Getting a Graph token for an agent takes three steps. The broker runs them in order, and each one has its own failure modes.
- Refresh. The broker takes the user's refresh token from the vault. The agent never sees it. The vault returns it under a handle, and the broker redeems that handle with Entra.
-
T1, with
fmi_path. The broker asks Entra for a token that names the blueprint and the agent identity. Entra's response carries anfmi_pathvalue, which says which agent identity the next step must act as. - OBO. The broker performs an on-behalf-of exchange, presenting the T1 token. The result is an access token whose audience is Graph and whose scopes are the ones the resource map allowed.
Here is what the broker checks on the final token. These are the properties we assert in the end-to-end test:
aud = https://graph.microsoft.com
scp = User.Read (only the scopes allowed for this agent)
azp = <the agent identity id> (the agent is the client on the token)
oid = <the user's object id> (the user is the subject of the access)
azp and oid together are the audit story. Someone reading a log can see that this agent, acting for this user, read
this resource. Neither name is missing, and neither can stand in for the other.
Why not a client secret?
A client secret would make the broker look like a single application, and every agent would share its rights. That loses the
azp distinction and makes it hard to revoke one agent without affecting the others. The blueprint and agent identity model
keeps a separate client for each agent, each with its own inheritable permissions. We also avoid secrets altogether: the
blueprint and the RP app use certificate credentials, and only the public half is uploaded to Entra. The private key stays
in compose/.secrets/tls/apps/, so a leaked Entra configuration does not leak a usable credential.
Creating the objects
The objects are created by Terraform and a provisioning script, not by hand. The script exists because the generic Terraform
Graph provider cannot send the OData-Version: 4.0 header that the Agent ID endpoints require, so it sends the requests
directly. Running the plan first is read-only:
cd compose
make real-plan
real-plan shows what would be created in the tenant behind your az login without changing anything. The apply step
creates the blueprint, its scope, its certificate and the agent identities, and it waits for them to become visible before
it continues. Newly created objects are not visible at once: a read straight after a create returned 404 in our testing, so
the script retries "not there yet" errors with backoff.
The order also matters. The scope must exist before a request can pre-authorise it, so the script writes the scope in one
step and the pre-authorisation in a second.
Example: the verification run
After the objects exist, make real-verify runs the whole chain for each agent and prints the claims it found. Here is the
shape of the output. The values are illustrative:
agent-orchestrator
refresh ok handle=vault:âĤa41
T1 (fmi_path) ok fmi_path=/agent/<agent-identity-id>
OBO ok aud=https://graph.microsoft.com scp=User.Read
Graph GET /me 200 displayName present
report-writer
refresh ok
T1 (fmi_path) ok
OBO ok aud=https://graph.microsoft.com scp=User.Read
Graph GET /me 200
A failing run tells you which step broke. If refresh fails, the user's session is gone or revoked. If T1 fails, the
blueprint or the agent identity is wrong. If OBO fails, the scope or the pre-authorisation is missing. We found the last one
the hard way, because a missing pre-authorisation shows up as a generic "permission" error.
The user's directory identifiers
Part 3 needs something more: proof that the directory entry for the user matches the Entra token. For that, the broker must
keep some ID-token claims past the sign-in, and give them back on request.
The option is BROKER_KEEP_ID_CLAIMS, off by default. When it is on, the OIDC callback stores an allow-list of claims in the
broker's auth_context:
preferred_username, upn, email, name, given_name, family_name, oid, tid
extension_<appIdNoHyphens>_adGuid
extension_<appIdNoHyphens>_adSid
extension_<appIdNoHyphens>_adSam
extension_<appIdNoHyphens>_adDn
Each value must be a non-empty string of at most 256 characters. The broker rejects anything that looks like a JWT, or
that is 80 or more characters of opaque text, so a token cannot be stored by accident under an innocent-looking name. The
store keeps identifiers only: never an access token, a refresh token or an assertion.
The claims are read back through an admin endpoint, POST /broker/admin/identity. The request names the user with
home_tid and home_oid, and it must be signed by the admin key as an ES256 JWT that includes the SHA-256 of the body. A
captured request cannot be replayed with a different body, and a request without a valid signature gets nothing:
curl -sS -X POST 'https://broker.example.com/broker/admin/identity' \
-H 'content-type: application/json' \
-H "authorization: Bearer $ADMIN_JWT" \
-d '{"home_tid":"<tenant id>","home_oid":"<user object id>"}'
The answer contains only the allow-listed claims, which is what the demo's Directory match card displays.
A limit we hit: the claims were not in the ID token
When we first tried this, the extension claims were missing from the ID token. We turned on BROKER_AUDIT_ENTRA_MESSAGE and
added the id_claim_names diagnostic, which records the claim names (never the values) the broker received. The names showed
the extension claims were absent for this multi-tenant app.
Our first fix was an optional-claims entry in Terraform, which adds the four extension claims to the ID token. That helps when
Entra emits them. In our tenant it did not, so the real card reads the same attributes through Microsoft Graph /me with the
user's own token. The broker does not need Graph permissions beyond what the user already consented to, and the match logic in
Part 3 is identical for both paths.
The next part puts those identifiers to work against a directory.
Top comments (0)