Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, &registry).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:

KindRule
ArrayEvery requested element must appear in the granted array
ObjectEvery requested field must exist in the granted object and be a subset of it (recursively)
PrimitiveMust 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, &registry).unwrap());
}
RuleModes
NumericFieldRuleCanReduce: the requested number must be less than or equal to the granted one. MustMatch: the numbers must be equal.
ArrayFieldRuleCanNarrow: 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, &registry).unwrap());

let report = explain_subset(&excessive, &granted, &registry).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 ComparatorConfig for 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 CanReduce for amounts and quantities, MustMatch for identifiers that must not change (account numbers, creditor IBANs), and CanNarrow for 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.