The answer is not a boolean
The last post was about what you ask a policy decision point when you put one behind a token endpoint. This one is about what it is allowed to say back, and it is the half I got wrong first.
I had assumed the answer was the easy part. You ask “may this scope be granted,” you get permit or deny, you mint accordingly. The design work is all in the question.
It isn’t. A boolean is a fine answer to “may Alice read this file.” It is a poor answer to “should I issue this token,” because almost every interesting issuance policy wants to say yes, but. Yes, but for five minutes rather than an hour. Yes, but only two of the three scopes. Yes, and here are the groups this subject belongs to, since you are about to write a JWT and I am the component that knows.
So the response carries structure. AuthZEN has the slot for it already: the
evaluation response has a context object, and a PDP can put what it likes in
there. The moment you use it, you inherit a question that turns out to be much
harder than it looks.
May an implementation ignore this field?
Every extensible protocol has to answer it. Some fields are safe for a receiver to skip when it doesn’t recognize them. Some are not, and skipping them silently produces behavior nobody intended.
AuthZEN answers it in one sentence, in the definition of the true value of
decision:
true: The access request is permitted to go forward. If the PEP does not understand information in the context response object, the PEP MAY choose to reject the decision.Authorization API 1.0, §5.5
MAY is the right call there. AuthZEN core deliberately declines to say what
response context means: the semantics are an implementation concern, out of
scope, by design. If you don’t know what a key means, you get to decide how
paranoid to be about it.
That is not sufficient for issuance, and the reason is worth stating precisely, because it is the hinge of everything below.
The failure is asymmetric
Consider an authorization server that receives a shaping key it doesn’t understand, and does the natural thing: ignores it and issues the token.
Same act, two entirely different consequences. On the left, the PDP said “you may issue this, for 300 seconds,” the AS heard “you may issue this,” and a token that policy wanted to live five minutes lives an hour. Nothing logs an error. Everything looks like it worked. On the right, the PDP said “and here are her groups,” the AS didn’t listen, and something downstream now has less information than it could have had. That is a bug. It is not a security failure.
Which gives a test:
A response-context key is mandatory-to-understand if and only if ignoring it would produce a token broader than the PDP authorized.
I like this more than I expected to, because of what it replaces.
Mandatory-to-understand is normally a judgment call, and usually a contested one. Someone thinks their extension is important enough that receivers should fail rather than skip it. Someone else points out that every mandatory field is a deployment blocker for anyone who hasn’t implemented it yet. The argument gets settled by whoever cares most, and the resulting tier list is a record of a negotiation rather than of a principle.
Here there is nothing to negotiate. You do not ask whether token_lifetime
feels important. You ask what happens if it is dropped, observe that the token
outlives its authorization, and you are done. Every key in the profile is
classified by applying that one sentence, and I would rather defend one
sentence than eleven separate decisions.
The second rule: the PDP decides how much, not what else
There is a second thing the return path needs, and it is the one that keeps it from quietly becoming a back door around the request path.
Suppose the AS asks about files.read and the PDP responds: permit, and by the
way, grant admin. Nothing in the mechanics above prevents that. The AS would
mint a token asserting a privilege that no evaluation ever considered.
A PDP MUST NOT return a shaping value that grants access the AS would not otherwise have granted, and an AS MUST reject a decision that attempts it.
Or, less formally: the PDP decides whether and how much. It does not decide
what else. Every shaping key can only narrow. token_lifetime is a ceiling
and never a floor. granted_scope must be a subset of what the AS was already
prepared to grant. audience must be a subset of the targets that were
actually evaluated.
This also keeps the earlier posts honest. The whole argument for asking a PDP at issuance is that the decision should be about the tuple that was evaluated, at the moment of issuance. A return path that could add authority would let the PDP answer a question it was never asked.
Someone else derived the same two rules
I want to flag something I found only after writing both rules down, because it is the best evidence I have that they aren’t my invention.
Transaction Tokens has a section about using a Txn-Token as the subject token of another exchange, which produces a replacement Txn-Token. It is a narrow case in a different specification solving a different problem. Here is part of what it requires of the token service:
- MAY enable modifications to asserted values that reduce the scope of permitted actions.
- MAY enable additional asserted values.
- MUST NOT enable modification to asserted values that expand the scope of permitted actions.
- MUST NOT modify
txn,sub, andaudvalues of the Txn-Token in the request.
That is the no-broadening rule, and then a short list of things that may never be touched at all. Arrived at independently, for a case that has nothing to do with policy decision points.
When two people working on unrelated problems write down the same invariant, it is usually because the invariant is a property of the problem rather than of either design. This one is: any time one component tells another to attenuate something it is about to issue, these are the rules, and the interesting question is only which fields are in which bucket.
What the test classifies
Applying it gives two tiers. The constraining keys, which an AS must understand or else treat the permit as a denial:
| Key | What it narrows |
|---|---|
granted_scope | the scope set to be granted |
token_lifetime | a ceiling in seconds, never a floor |
audience | the targets the token may name |
authorization_details | the RFC 9396 entries, by replacement |
crit | see below |
And one decorating key, claims, which is advisory: a map of claim names to
values, for the attributes a policy computes while it decides. Drop them and
you issue a less useful token.
The groups, roles, and entitlements claims that
RFC 9068 says a JWT access token
should carry are a special case, and a more interesting one than they look.
They are not attributes computed while deciding; they are enumerations of
what a subject holds, and AuthZEN has an operation built for exactly that
question. A PDP may certainly return them here, but it is not the better
mechanism, and the next post is about the one that is.
All of it lives under one issuance member of the response context rather than
scattered at top level:
{
"decision": true,
"context": {
"reason_admin": { "200": "matched policy P-4471" },
"issuance": {
"token_lifetime": 300,
"claims": { "groups": ["engineering", "release-managers"] }
}
}
}
The envelope earns its keep by making “do I implement this profile?” a single key lookup, which is what makes the fail-closed rule cheap enough that people will actually implement it. It also leaves the rest of the context object alone, so reason strings and vendor keys keep their ordinary advisory behavior.
Where the static tiering runs out
The test classifies keys. It cannot classify values, and there is a case where that matters.
Additive claims are safe to ignore, by the argument above. Except when the
claim is itself a constraint that something downstream is expected to enforce.
Transaction Tokens carries a tctx object of policy-determined transaction
context; suppose a deployment’s policy puts a ceiling in there, and a
downstream service is built to check it. Now dropping an additive claim
broadens what the token effectively authorizes. The static tier is wrong, not
because the rule is wrong, but because whether this particular value is a
constraint is a fact about the deployment’s policy, and the profile cannot know
it.
So the PDP gets to say. Borrowed straight from JWS, where the same problem has the same shape:
"issuance": {
"claims": { "tctx": { "amount": 500, "amount_ceiling": 1000 } },
"crit": ["tctx"]
}
crit names members of claims that are mandatory-to-understand for this
response. An AS that doesn’t understand and apply a named member must treat the
permit as a denial. It is the escape hatch that lets the static classification
be strict without being a straitjacket, and it also means a future extension
can become mandatory-to-understand without anyone revising this profile.
The obvious objection is that an attacker who can strip crit has defeated the
whole thing. True, and irrelevant: an attacker with that access can just as
easily flip decision to true. crit adds no attack surface that transport
security didn’t already have to cover.
The two claims a PDP may never write
There is a list of reserved claim names in the profile, most of which are
housekeeping. A PDP shouldn’t set iss or jti, because the AS is
authoritative for provenance. It shouldn’t set exp, because token_lifetime
already expresses that and there should be exactly one way to say a thing. It
shouldn’t set cnf, because key binding derives from a proof the AS verified,
and a PDP-supplied cnf would let the PDP bind the token to a key of its
choosing.
Two of them are not housekeeping at all: sub and aud.
Those are not claims. They are the five-tuple. sub is subject.id and aud
is resource.id, which is to say they are the inputs to the decision the PDP
just made. A PDP that could rewrite either would cause the AS to issue a token
corresponding to a decision that was never evaluated.
Re-subjecting a token is not attenuation. It is a fresh issuance, and it has to be evaluated as one.
Note where TraTs’ txn, sub, aud prohibition ends up under this framing:
not as a special case that the transaction tokens binding has to restate, but
as a consequence of the general rule. Those claims are outside the PDP’s reach
by construction, in every binding, for the same reason.
The part that will bite implementers
Now the part I am least confident about, which I mention because it is exactly the part a first draft usually gets wrong.
The framework fans a request out into several evaluations: a gate tuple, then one tuple per scope. AuthZEN batch responses carry no top-level context. Each element of the array has its own.
But a token has one lifetime. One claim set. One audience list. So per-item shaping has to compose, and the profile has to say how:
| Key | Aggregation |
|---|---|
token_lifetime | minimum over permitted items |
granted_scope | union, then intersected with what the AS would grant anyway |
audience | intersection over permitted items |
authorization_details | union of entries, then the structural check |
crit | union |
claims | merge, and on conflict reject |
Every row is chosen to preserve narrowing, which is the only principle
available. Minimum for a ceiling. Intersection for a set of permitted targets.
Union for crit, because one item marking a claim critical makes it critical
for the token that gets minted.
claims is the awkward one. If two permitted items return different values for
the same claim name, there is no safe merge, because there is no general
narrowing operation on arbitrary JSON values and picking one arbitrarily could
broaden the result. So the AS rejects the whole decision. That is
unsatisfying, and the practical advice is for PDPs to put token-level shaping
on a single item and avoid the situation entirely.
This section is invisible until someone actually batches, which is why I expect it to be where the first implementation report lands.
The boundary I couldn’t push further
authorization_details is where narrowing stops being checkable, and I think
it is worth being blunt about rather than papering over.
RAR entries are arbitrary structured JSON. ID-JAG already contemplates a policy engine reshaping them: the IdP “MAY modify, filter, or omit authorization details based on policy.” But “narrower” is not decidable for an arbitrary authorization details type. Whether one payment-initiation entry is narrower than another depends on what that type means, and the profile does not know what any type means.
So it splits:
- Always checkable. Every returned entry must have a
typethat was in the request, and itslocations,actions, anddatatypesarrays must be subsets of the request’s. Those are the members RFC 9396 defines, so they can be compared generically. - Everything else. For type-specific members, the AS must either have a validator for that type or reject the decision. Fail closed. Do not pass unvalidated structure into a token you are about to sign.
A framework that claimed to validate arbitrary RAR narrowing would be lying, and the honest version is more useful than the impressive one.
What is still open
One thing in the return path is genuinely unresolved, and it is not a detail.
A PDP that refuses because the subject authenticated too weakly knows
something the client could act on: which acr and amr values would have made
the answer a permit. That is a machine-actionable response to “why was I
refused,” and it is a much better one than a bare denial.
Neither end of it is specified. AuthZEN illustrates the pattern, in a
non-normative example of a denial whose context carries acr_values and
amr_values, but it does not define those keys, so there is nothing a profile
can normatively point at. And even given a defined signal, the token endpoint
has nowhere to put it: the natural mapping is
insufficient_user_authentication, and
RFC 9470 defines that for resource
servers, with no equivalent at the token endpoint. So the profile says an AS
should surface the requirement, declines to define how, and flags it as an
open item.
Step-up at issuance needs work at both ends, then. That is a reasonable shape for a document. I do not have it yet.
I went looking at what the neighbouring specs do about denial signalling more
generally, and the answer is less than you would hope. RFC 8693 mandates
invalid_request for a policy refusal, which is a category error by RFC 6749’s
own definition, since the request is perfectly well formed. Identity chaining
points at RFC 7523. ID-JAG describes policy evaluation in detail, says granted
scopes may be a subset of those requested, and names no error code for either a
total refusal or a partial one. Transaction Tokens names none at all.
Which means the partial grant, the thing this entire series is about, currently has no signalling mechanism in any of them. That is a gap worth its own document, and possibly its own post.
Where this is going
- AuthZEN Profile for OAuth 2.0 Token Issuance - the framework. Section 6 is this post (editor’s copy).
- AuthZEN Binding for OAuth 2.0 Token Exchange - RFC 8693, identity chaining, ID-JAG and transaction tokens (editor’s copy).
Next, and last in the series as currently planned:
- Claims enrichment is a search. Using AuthZEN’s search endpoints to populate the
groups,roles, andentitlementsclaims of RFC 9068 - and what happens to an authorization server once its token depends on more than one answer.
Comments, disagreements, and pull requests are welcome in the repo.