Subset Comparison
RFC 9396 splits the OAuth 2.0 flow into two steps. At the authorization
endpoint the client is granted a set of authorization_details; at the token
endpoint it re-requests a (possibly narrower) set. The token endpoint must
reject any request that is not a subset of the original grant: the client
may narrow what it asked for, never widen it.
is_subset implements that check.
Calling is_subset
#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;
let granted = vec![AuthorizationDetail::new(
"payment_initiation",
serde_json::json!({
"actions": ["initiate", "status", "cancel"],
"instructedAmount": {"currency": "EUR", "amount": 1000.0}
}),
)];
let requested = vec![AuthorizationDetail::new(
"payment_initiation",
serde_json::json!({
"actions": ["initiate"],
"instructedAmount": {"currency": "EUR", "amount": 1000.0}
}),
)];
let registry: Vec<TypeSchema> = vec![]; // empty registry = structural comparison
assert!(is_subset(&requested, &granted, ®istry).unwrap());
}
For each requested entry, is_subset looks for any granted entry of the
same type whose detail object covers it; when a type was granted several
times, each grant is tried. A requested type that was never granted yields
false. Policy violations – including malformed client values such as a
non-numeric amount – yield false; only a broken comparator rule yields
Err(SubsetError).
Structural comparison
Without a type registry (or for types registered without a
ComparatorConfig), comparison is structural:
| Kind | Rule |
|---|---|
| Array | Every requested element must appear in the granted array |
| Object | Every requested field must exist in the granted object and be a subset of it (recursively) |
| Primitive | Must match exactly |
Under structural rules the requested amount above would be rejected:
lowering the amount from 1000.0 to 500.0 fails, because 500.0 does not
exactly equal 1000.0. Numeric narrowing only works with typed comparison.
Typed comparison with ComparatorConfig
Register a ComparatorConfig on the type schema to declare which fields may
narrow and how:
#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;
let granted = vec![AuthorizationDetail::new(
"payment_initiation",
serde_json::json!({
"actions": ["initiate", "status", "cancel"],
"instructedAmount": {"currency": "EUR", "amount": 1000.0}
}),
)];
let requested = vec![AuthorizationDetail::new(
"payment_initiation",
serde_json::json!({
"actions": ["initiate"],
"instructedAmount": {"currency": "EUR", "amount": 500.0}
}),
)];
let comparator = ComparatorConfig::new()
.add_numeric_field(NumericFieldRule::new(
"$.instructedAmount.amount",
NumericComparisonMode::CanReduce,
))
.add_array_field(ArrayFieldRule::new(
"$.actions",
ArrayComparisonMode::CanNarrow,
));
let schema = TypeSchema::new(
"payment_initiation",
serde_json::json!({
"type": "object",
"required": ["actions", "instructedAmount"],
"properties": {
"actions": {"type": "array"},
"instructedAmount": {"type": "object"}
}
}),
"Payment initiation authorization",
)
.with_comparator(comparator);
let registry = vec![schema];
assert!(is_subset(&requested, &granted, ®istry).unwrap());
}
| Rule | Modes |
|---|---|
NumericFieldRule | CanReduce: the requested number must be less than or equal to the granted one. MustMatch: the numbers must be equal. |
ArrayFieldRule | CanNarrow: the requested array must be a subset of the granted array. MustMatch: the arrays must be equal. |
Fields not covered by a rule fall back to structural comparison, so the
currency above must still match exactly – even though it sits next to the
covered amount. Numeric rules accept RFC 9396 decimal strings
("123.50"). If a ruled field is present on only one side, the request is
not a subset; if it is absent on both, the rule is skipped.
Rule paths must name a single field, in dot ($.instructedAmount.amount)
or bracket ($['instructedAmount']['amount']) notation. Wildcards,
indices, filters and recursive descent are rejected with
SubsetError::InvalidComparator.
Rejection is a value, not an error
is_subset returns bool for every legitimate comparison. A request that
asks for more – a larger amount, an action that was not granted, a field
the grant never had – simply returns false. When you need to tell the
client why, explain_subset returns a SubsetReport listing each
uncovered entry with the first field that exceeds the grant; the text is
ready for an invalid_authorization_details error description:
#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;
let granted = vec![AuthorizationDetail::new(
"payment_initiation",
serde_json::json!({
"actions": ["initiate", "status", "cancel"],
"instructedAmount": {"currency": "EUR", "amount": 1000.0}
}),
)];
let excessive = vec![AuthorizationDetail::new(
"payment_initiation",
serde_json::json!({
"actions": ["initiate"],
"instructedAmount": {"currency": "EUR", "amount": 1500.0}
}),
)];
let registry: Vec<TypeSchema> = vec![]; // structural: 1500.0 does not equal 1000.0
assert!(!is_subset(&excessive, &granted, ®istry).unwrap());
let report = explain_subset(&excessive, &granted, ®istry).unwrap();
assert_eq!(
report.rejections[0].reason,
SubsetReason::FieldNotCovered { path: "$.instructedAmount.amount".to_string() }
);
assert_eq!(
report.rejections[0].to_string(),
"authorization_details[0] (payment_initiation): $.instructedAmount.amount exceeds the grant"
);
}
A requested type that was never granted is reported as
SubsetReason::TypeNotGranted. With a TypeRegistry, the same checks are
registry.is_subset(&requested, &granted) and
registry.explain_subset(&requested, &granted), using the comparators
registered with each type.
Reserve Err(SubsetError) handling for misconfiguration: an
InvalidComparator error means a registered rule is broken, not that the
client misbehaved. A TypeRegistry rejects such rules when the type is
registered.
Best practices
- Register a
ComparatorConfigfor every type whose detail object contains numbers or arrays that may legitimately narrow. Without it, a re-request that merely lowers an amount is rejected as a structural mismatch. - Use
CanReducefor amounts and quantities,MustMatchfor identifiers that must not change (account numbers, creditor IBANs), andCanNarrowfor action lists. - Evaluate subset checks at the token endpoint, before issuing the token, so an over-broad re-request never becomes a credential.
Next steps
Delegation uses the same merge idea on two grants instead of a grant and a re-request; see Delegation.