Omri Gazitt

Mapping RAR to AuthZEN with COAZ

· 9 min read

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:

PDPAuthorizationserverPayments APIClientPDPAuthorizationserverPayments APIClientthe token doesn'tcover this paymentPOST /payments (sessiontoken)401 insufficient_authorization +the RAR entryauthorize, then POST /token with the entryinitiate oncreditor_account:DE02... (EUR123.50)permitaccess token carrying the entryPOST /payments (new token)initiate on creditor_account:DE02... (EUR 123.50)permit200 payment accepted

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:

PDPAuthorizationserverCustomers APICRM clientPDPAuthorizationserverCustomers APICRM clientthe token coversread:contacts, andthe call names c-77authorize, then POST /token with the entryread:contacts oncustomer_information:/custome-rspermitaccess token carrying the entryGET /customers/c-77/contactsread:contacts on customer:c-77permit200 contactsGET /customers/c-99/contactsread:contacts on customer:c-99deny403 Forbidden

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:

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.