Skip to main content

cpex_core/extensions/
authorization.rs

1// Location: ./crates/cpex-core/src/extensions/authorization.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// AuthorizationDetail — RFC 9396 Rich Authorization Requests.
7//
8// Carried on DelegationHop alongside `scopes_granted`. Each hop can narrow
9// the details structurally (drop entries, remove actions, add constraints).
10// The narrowing-check helper lives elsewhere (framework enforcement at the
11// TokenDelegate boundary, per docs/specs/delegation-hooks-rust-spec.md §9.6).
12
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16/// A single RFC 9396 authorization_details entry.
17///
18/// `type` is required (renamed `detail_type` here to avoid the Rust
19/// keyword). The remaining fields are optional per the RFC. API-specific
20/// extension fields are captured in `extra` via serde flatten.
21#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
22pub struct AuthorizationDetail {
23    #[serde(rename = "type")]
24    pub detail_type: String,
25
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub locations: Option<Vec<String>>,
28
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub actions: Option<Vec<String>>,
31
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub datatypes: Option<Vec<String>>,
34
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub identifier: Option<String>,
37
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub privileges: Option<Vec<String>>,
40
41    /// API-specific fields not covered by the named RFC 9396 fields above.
42    /// Subsetting checks treat these opaquely (exact equality).
43    #[serde(flatten)]
44    pub extra: BTreeMap<String, serde_json::Value>,
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn serde_roundtrip_with_rfc9396_keyword() {
53        let detail = AuthorizationDetail {
54            detail_type: "tool_invocation".into(),
55            actions: Some(vec!["read".into()]),
56            identifier: Some("get_compensation".into()),
57            ..Default::default()
58        };
59        let json = serde_json::to_string(&detail).unwrap();
60        // The `type` field on the wire, not `detail_type`.
61        assert!(json.contains(r#""type":"tool_invocation""#));
62        assert!(!json.contains("detail_type"));
63
64        let back: AuthorizationDetail = serde_json::from_str(&json).unwrap();
65        assert_eq!(back, detail);
66    }
67
68    #[test]
69    fn extra_fields_round_trip() {
70        let json = r#"{
71            "type": "payment",
72            "actions": ["initiate"],
73            "amount": "100.00",
74            "currency": "USD"
75        }"#;
76        let detail: AuthorizationDetail = serde_json::from_str(json).unwrap();
77        assert_eq!(detail.detail_type, "payment");
78        assert_eq!(
79            detail.extra.get("amount").and_then(|v| v.as_str()),
80            Some("100.00")
81        );
82        assert_eq!(
83            detail.extra.get("currency").and_then(|v| v.as_str()),
84            Some("USD")
85        );
86    }
87}