We built the broker against a mock Entra first. The mock made the flow look simple: tokens came back, claims matched, the
tests passed. Moving to a real tenant changed that. This part lists what the real service did differently, with the symptom
we saw, the cause we found and the fix we shipped. Each one now has code or a comment behind it, so the next person does not
have to rediscover it.
Newly created objects are not visible at once
Symptom. A GET straight after creating a blueprint returned 404. A pre-authorisation written right after its scope failed
with 400 Permission Id that cannot be found. A consent query right after creating a service principal also returned 404.
Cause. Entra replicates new objects. A write can succeed while a read from another region still sees nothing.
Fix. The provisioning script waits for each new object to appear, and it retries "not there yet" errors with a short,
growing delay. It also writes the scope and the pre-authorisation as two separate requests, because the second depends on the
first being visible. In practice that means the apply step is slower on a fresh tenant and quick on a second run.
create blueprint ok
wait for blueprint 404 retry in 2s
wait for blueprint 200
create scope ok
wait for scope 404 retry in 2s
wait for scope 200
pre-authorise RP app ok
The trace above is a representative sketch of the retry loop, not a copy of a specific run; the 404 and 400 symptoms are the ones we recorded.
Credential identifiers come back in another form
Symptom. After uploading a certificate to the blueprint, the script could not tell whether the credential was already there.
Its record of the key identifier did not match the one Entra returned.
Cause. When we send a certificate, we send a base64 key identifier. Entra returns customKeyIdentifier as an uppercase
hex SHA-1 thumbprint, not base64. Two forms of one value looked like two different keys.
Fix. The script matches either form, keeps credentials it did not create, and collapses duplicates. Running the apply twice
now leaves exactly one credential, not one per run.
Some "already exists" answers are not the documented ones
Symptom. Adding an inheritable permission that already existed returned 400 already exists. The documentation says 409.
Fix. The script treats both as "already done". Checking the status code alone would have failed the second run.
The resolver and IPv6 on an IPv4-only network
Symptom. The broker's container could not reach login.microsoftonline.com. The error was ENETUNREACH, even though the
host had working internet access.
Cause. The first image was Alpine, which uses musl. On an IPv4-only network musl still tried the IPv6 address that
Microsoft publishes, and the connection failed without falling back.
Fix. We moved the broker and the demo images to Debian slim, and set Node to prefer IPv4 first with
--dns-result-order=ipv4first. Setting the lookup order is the explicit guard, so it does not depend on the image's resolver defaults.
# quick check from inside the broker container
node -e "require('dns').lookup('login.microsoftonline.com', { all: true }, (e, a) => console.log(e || a))"
Once the fix is in, the lookup returns IPv4 addresses first and the sign-in proceeds.
Graph names its audience two ways
Symptom. The check aud == https://graph.microsoft.com passed in one run and failed in another for the same kind of token.
Cause. Graph will name its audience either as the application's client id or as its URL. Both are valid, and which one you get
depends on the request.
Fix. The verifier accepts either form, and the test asserts that both are accepted. A test that only accepted one would have
failed intermittently, which is worse than failing always.
Windows and Terraform
Symptom. A local-exec step that worked on a Linux machine failed under Windows with a path error. Quoted paths with spaces
were broken into several arguments.
Cause. Terraform runs local-exec through cmd on Windows, and cmd treats quotes differently.
Fix. The command is passed as a list of arguments rather than a single string. The command field must be non-empty, so it
carries the mode (apply or destroy) and the script reads its real configuration from the environment. Keeping the apply and
the teardown in separate resources also means a configuration change, which re-runs the idempotent apply, can never delete the
blueprint or change the client ids the broker is configured with.
Revoking a user does not stop tokens already issued
This was the most important finding, and it is the one we are least comfortable with.
Setup. We measured what happens when a user's sessions are revoked with revokeSignInSessions, and how quickly the broker and
the Azure CLI notice.
Result. Revocation ends the sign-in, and the Azure CLI's session was ended through the
TokenIssuedBeforeRevocationTimestamp check. The broker's own cached state, however, was a separate gap. A base token it had
cached could still be reused until its own lifetime ran out.
Mitigation. Three measures, none of which is complete on its own:
- The broker bounds how long a cached base token may be reused. The setting is
BASE_TOKEN_MAX_REUSE_SEC, and the broker never reuses a token past that age. - When the broker's admin revoke endpoint is called for a user, it removes that user's stored refresh tokens and cached base tokens from the vault. That closes the broker side immediately.
- The design documents that a token already issued to an agent stays valid until it expires. We would rather state that limit than present a green test as proof of full revocation.
# admin-signed revoke, body hash included in the signed JWT
curl -sS -X POST 'https://broker.example.com/broker/admin/revoke' \
-H 'content-type: application/json' \
-H "authorization: Bearer $ADMIN_JWT" \
-d '{"home_tid":"<tenant id>","home_oid":"<user object id>"}'
Logs that help without leaking
Diagnosing these issues needed the Entra error text, but the broker must never log tokens. The solution was a pair of flags and a
helper:
-
BROKER_AUDIT_ENTRA_MESSAGEturns on the Entra error message in the audit log. It is off by default, and the helper strips anything that looks like a token before the message is written. -
describeUpstreamErrorconverts an error into a stable summary: the status, the Entra error code and a short message, never the request body. - The audit allow-list names every field it may write. A new field is not logged until someone adds it deliberately.
When we turned on the message flag to debug the missing extension claims, the log showed the Entra error codes. Two of them,
50173 and 70043, mean the user must sign in again, so the broker now maps them to interaction_required instead of a generic
failure. That is the kind of detail the mock would never have shown us.
Consent in another tenant
Symptom. A home-tenant user acting as a B2B guest in a second tenant got AADSTS65001 (consent required) on the first call.
Cause. The guest tenant had not consented to the blueprint's permissions, and the consent prompt must happen in that tenant.
Fix. The exchange maps 65001 on a non-home tenant to interaction_required, and starts a consent-only step-up flow for
that tenant. The user consents once there and the next exchange succeeds. This is how O1 was settled: a guest in the second tenant
received a token from that tenant's own blueprint and agent identity.
Summary of what changed
| Area | Mock behaviour | Real behaviour | What we did |
|---|---|---|---|
| New objects | visible at once | 404 for a while | wait and retry with backoff |
| Key ids | one form | two forms | match either |
| Duplicate permission | 409 | 400 | treat both as done |
| Network | assumed IPv4 | IPv6 first, musl failed | Debian slim, IPv4 first |
| Audience | URL only | URL or client id | accept either |
| Revocation | instant | sign-in ends, cache lingers | bounded reuse, vault cleared on revoke |
The next and final part covers what is still unverified, and how to run the stack yourself.
Top comments (0)