Mapping RAR to AuthZEN with COAZ
We’re handing more and more work to agents, and every task we delegate comes with a question attached: how much authority should the agent get to do it? The honest answer is almost always “less than a scope string can express.” An agent paying an invoice needs to pay that invoice, to that payee, for that amount. Least privilege for agents means tokens constrained to the task, and that means fine-grained authorization at two moments: when the token is issued, and when it’s used.
Those two moments grew up in different communities.
Two ecosystems that grew up apart
On the OAuth side, the tool for constrained tokens is Rich Authorization
Requests (RFC 9396). Instead of a
scope, the client sends a JSON authorization_details array, and each entry
describes what it wants in structured form. RAR is widely deployed in open
banking and health, and RFC 9396 is explicit that how the authorization server
decides what to grant is up to the authorization server.
On the AuthZEN side, the working group has mostly focused on the resource server. The Authorization API gives an RS a standard way to ask a policy decision point (PDP) “may this subject do this action on this resource?” every time a request arrives.
The AuthZEN profiles for OAuth token issuance
are an attempt to connect the two: an AS asks a PDP whether to issue a token,
and what to put in it. For RAR, the issuance profile
breaks each entry down along the fields RFC 9396 defines for every type:
type becomes the resource type, each locations value a resource, and each
actions and datatypes value part of the action name. I wrote about why that
works in A RAR entry is a product.
That covers the generic shape of RAR. It doesn’t cover the shapes people actually deploy. RFC 9396’s own lead example is a payment:
{
"type": "payment_initiation",
"actions": ["initiate", "status", "cancel"],
"locations": ["https://example.com/payments"],
"instructedAmount": { "currency": "EUR", "amount": "123.50" },
"creditorName": "Merchant A",
"creditorAccount": { "iban": "DE02100100109307118603" }
}
The generic decomposition turns that into “may Alice initiate at
example.com/payments?” The creditor and the amount, which are what a payment
policy is actually about, never reach the PDP. Open banking consents, health
care’s patient and purpose-of-use fields: same story. The meaning lives in
members the type defines for itself, and only whoever defined the type knows
what they mean.
A place to put that knowledge
The OAuth working group recently adopted Yaron Zehavi’s
OAuth 2.0 RAR Metadata and Error Remediation.
It does two things. It gives the AS a metadata endpoint that publishes a JSON
Schema for each authorization_details type it supports. And it gives an RS
that refuses a call a way to tell the client exactly which entry to go and ask
for: a 401 with an authorization_remediation parameter carrying the entry,
built from the call that was refused.
A schema tells you what a valid entry looks like. It doesn’t tell a PDP which members matter or how they map onto a subject, an action and a resource. But the per-type metadata object is exactly the right place to say so.
COAZ already solved this shape of problem
The AuthZEN working group has a framework for this called COAZ. A COAZ mapping is a template for an AuthZEN request where any value can be a CEL expression over the operation’s inputs. Its first binding, COAZ-MCP, lets an MCP server put a mapping in a tool’s input schema, so a gateway that knows nothing about the server’s business can still ask a PDP the right question about every call to that tool.
That’s the same split RAR has. One party defines a type, and other parties have
to enforce it. So I’ve written
COAZ-RAR,
a COAZ binding for authorization_details types. The type’s definer publishes a
mapping in the type’s metadata entry, and two enforcement points apply it:
- the AS, at issuance, for each cell of each requested entry, in place of the generic decomposition;
- the RS, when a call arrives, for the entry the call amounts to.
Same mapping, same vocabulary, at both ends.
A payment, end to end
Here’s the mapping for payment_initiation:
{
"evaluation": {
"action": {
"name": "$cell.action",
"properties": {
"amount": "$detail.instructedAmount.amount",
"currency": "$detail.instructedAmount.currency"
}
},
"resource": {
"type": "creditor_account",
"id": "$detail.creditorAccount.iban"
}
}
}
And here’s the flow, with the AuthZEN calls shaded inside the OAuth ones:
The AS and the RS ask the PDP the identical question:
{
"subject": { "type": "user", "id": "alice" },
"action": {
"name": "initiate",
"properties": { "amount": "123.50", "currency": "EUR" }
},
"resource": { "type": "creditor_account", "id": "DE02100100109307118603" }
}
They match because the remediation entry is the RS’s own description of the call. The AS evaluates, before the token exists, exactly what the RS will see when the call is retried.
Why does the amount go in action.properties instead of being a resource of
its own? The five fields at the core of an
AuthZEN request are where decision-critical inputs belong, because every PDP
can read them. But an amount isn’t an entity. It has no identity and no
relationships, and a relationship-based engine (OpenFGA, SpiceDB, or Topaz,
which my company built) handles a value like that as context for a
condition, not as an object. AuthZEN already says action properties are for
“parameters of the action that is being requested.” The payee is an entity,
so it goes in the resource. The amount is a parameter, so it goes on the action.
A standing grant, end to end
Not every grant starts with a refused call. Say a CRM client asks up front for a standing grant to read customer contacts. The entry names the API but no particular customer:
{
"type": "customer_information",
"locations": ["https://example.com/customers"],
"actions": ["read"],
"datatypes": ["contacts"]
}
So at issuance, the only question the AS can ask is about the collection. Later
the client calls GET /customers/c-77/contacts, and now the RS knows exactly
which customer the call is about. That’s the question it should ask.
RFC 9396 already has a field for “a specific resource available at the API”:
identifier. The RS fills it in when it describes the call, and the mapping
uses CEL’s conditional operator to ask the right question in each case:
{
"evaluation": {
"action": { "name": "$cell.name" },
"resource": {
"type": "$has(detail.identifier) ? 'customer' : 'customer_information'",
"id": "$has(detail.identifier) ? detail.identifier : cell.location"
}
}
}
(cell.name is the action name the issuance profile already forms for a cell,
here read:contacts.) Here’s how it plays out:
The AS asks:
{
"subject": { "type": "user", "id": "alice" },
"action": { "name": "read:contacts" },
"resource": {
"type": "customer_information",
"id": "https://example.com/customers"
}
}
and the RS asks:
{
"subject": { "type": "user", "id": "alice" },
"action": { "name": "read:contacts" },
"resource": { "type": "customer", "id": "c-77" }
}
Different questions, on purpose, from one mapping in one vocabulary. The AS is
deciding whether Alice should hold this kind of access at all, which a policy
might answer by role. The RS is deciding whether she may read this customer,
which a policy might answer by her relationship to it: Alice manages c-77
and not c-99.
Two things follow. First, a token that covers the class doesn’t cover every
instance. The call about c-99 gets a plain 403, not a remediation challenge,
because no token would change the answer. Second, the policy can evolve. If
today’s policy answers both questions by role, it can move to “Alice may read
the customers she manages” without either enforcement point changing, because
the RS was asking about the instance all along. That’s a big part of why you
externalize authorization in the first place.
And if the RS had refused the c-77 call and remediated instead, the entry it
handed back would have carried identifier: c-77, and the same mapping would
have had the AS ask about the instance too.
MCP tools, and where it stops
An MCP tool’s COAZ-MCP mapping runs unchanged as a RAR type’s mapping. The
binding exposes the entry as params.arguments and the token the AS is about to
mint as token, which is all a tool mapping refers to. So an MCP gateway can
refuse a tool call, hand back the call’s arguments as a RAR entry, and the AS
decides on those exact arguments at issuance. Issuance usually can’t see the
specific call; with remediation, it can.
The mapping’s author decides how good the question is. The COAZ-MCP spec’s own
example buckets the transfer amount into sensitivity: high above 10,000, so a
PDP can tell 5,000 from 25,000 but not 25,000 from 60,000, even though the
gateway has the real number. Nothing in COAZ, or in this binding, stops a
mapping from leaving the decisive input out, which is why the draft has a short
section of guidance for mapping authors.
And some RAR shapes still don’t fit. RFC 9396’s file example lists (path, access) pairs inside a type-specific array. COAZ deliberately doesn’t let an expression decide how many questions get asked, and even if it did, the AS couldn’t rebuild a narrower array from the pairs that were permitted without knowing the type.
Where it goes from here
The draft is an editor’s copy for now:
- COAZ-RAR: COAZ Binding for OAuth 2.0 Rich Authorization Requests
- open questions as issues,
including whether the generic decomposition should use
identifierby default, and where the mapping officially lives in Yaron’s metadata
The next step is to take it to the AuthZEN working group, so it can sit next to COAZ-MCP as a working group draft. If you deploy RAR with custom types, I’d especially like to hear whether your types map cleanly, and where they don’t.