Skip to main content

adk_payments/auth/
binding.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Map, Value};
3
4use crate::domain::TransactionRecord;
5
6use super::{PaymentOperation, PaymentsAuthError, check_payment_operation_scopes};
7
8/// Authenticated request identity carried into payment tools or endpoints.
9///
10/// This type intentionally keeps request identity separate from the durable
11/// session identity stored on a transaction and from the protocol actor roles
12/// recorded in commerce payloads.
13///
14/// # Example
15///
16/// ```
17/// use adk_payments::auth::{AuthenticatedPaymentRequest, PaymentOperation};
18///
19/// let request = AuthenticatedPaymentRequest::new("alice")
20///     .with_tenant_id("tenant-1")
21///     .with_scopes(["payments:checkout:update"]);
22///
23/// request
24///     .check_operation_scopes(PaymentOperation::UpdateCheckout)
25///     .unwrap();
26/// ```
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct AuthenticatedPaymentRequest {
30    pub user_id: String,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub session_id: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub tenant_id: Option<String>,
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub scopes: Vec<String>,
37    #[serde(default, skip_serializing_if = "Map::is_empty")]
38    pub metadata: Map<String, Value>,
39}
40
41impl AuthenticatedPaymentRequest {
42    /// Creates a new authenticated payment request capsule.
43    #[must_use]
44    pub fn new(user_id: impl Into<String>) -> Self {
45        Self {
46            user_id: user_id.into(),
47            session_id: None,
48            tenant_id: None,
49            scopes: Vec::new(),
50            metadata: Map::new(),
51        }
52    }
53
54    /// Attaches the caller's session identifier.
55    #[must_use]
56    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
57        self.session_id = Some(session_id.into());
58        self
59    }
60
61    /// Attaches the caller's tenant identifier.
62    #[must_use]
63    pub fn with_tenant_id(mut self, tenant_id: impl Into<String>) -> Self {
64        self.tenant_id = Some(tenant_id.into());
65        self
66    }
67
68    /// Replaces the granted scope list.
69    #[must_use]
70    pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
71    where
72        I: IntoIterator<Item = S>,
73        S: Into<String>,
74    {
75        self.scopes = scopes.into_iter().map(Into::into).collect();
76        self
77    }
78
79    /// Adds one metadata field preserved alongside the authenticated request.
80    #[must_use]
81    pub fn with_metadata(mut self, key: impl Into<String>, value: Value) -> Self {
82        self.metadata.insert(key.into(), value);
83        self
84    }
85
86    /// Checks that the request scopes authorize one payment operation.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`PaymentsAuthError::MissingScopes`] when the request is missing
91    /// one or more required scopes.
92    pub fn check_operation_scopes(
93        &self,
94        operation: PaymentOperation,
95    ) -> Result<(), PaymentsAuthError> {
96        check_payment_operation_scopes(operation, &self.scopes)
97    }
98
99    /// Rejects attempts to access a transaction with conflicting identity or
100    /// tenant bindings.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`PaymentsAuthError::IdentityConflict`] when the authenticated
105    /// request tries to rebind the durable session or tenant association of an
106    /// existing transaction.
107    pub fn assert_transaction_binding(
108        &self,
109        record: &TransactionRecord,
110    ) -> Result<(), PaymentsAuthError> {
111        if let Some(session_identity) = &record.session_identity {
112            let expected_user = session_identity.user_id.as_ref();
113            if self.user_id != expected_user {
114                return Err(PaymentsAuthError::IdentityConflict {
115                    transaction_id: record.transaction_id.to_string(),
116                    binding: "session_user_id",
117                    expected: expected_user.to_string(),
118                    actual: self.user_id.clone(),
119                });
120            }
121
122            if let Some(session_id) = &self.session_id
123                && session_id != session_identity.session_id.as_ref()
124            {
125                return Err(PaymentsAuthError::IdentityConflict {
126                    transaction_id: record.transaction_id.to_string(),
127                    binding: "session_id",
128                    expected: session_identity.session_id.to_string(),
129                    actual: session_id.clone(),
130                });
131            }
132        }
133
134        if let Some(request_tenant_id) = &self.tenant_id
135            && let Some(expected_tenant_id) = &record.initiated_by.tenant_id
136            && request_tenant_id != expected_tenant_id
137        {
138            return Err(PaymentsAuthError::IdentityConflict {
139                transaction_id: record.transaction_id.to_string(),
140                binding: "tenant_id",
141                expected: expected_tenant_id.clone(),
142                actual: request_tenant_id.clone(),
143            });
144        }
145
146        Ok(())
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use chrono::{TimeZone, Utc};
153    use serde_json::json;
154
155    use super::*;
156    use crate::domain::{
157        Cart, CartLine, CommerceActor, CommerceActorRole, CommerceMode, MerchantRef, Money,
158        ProtocolExtensions, TransactionId,
159    };
160
161    fn sample_transaction() -> TransactionRecord {
162        let created_at = Utc.with_ymd_and_hms(2026, 3, 22, 12, 0, 0).unwrap();
163        let mut record = TransactionRecord::new(
164            TransactionId::from("tx-tenant"),
165            CommerceActor {
166                actor_id: "shopper-agent".to_string(),
167                role: CommerceActorRole::AgentSurface,
168                display_name: Some("shopper".to_string()),
169                tenant_id: Some("tenant-1".to_string()),
170                extensions: ProtocolExtensions::default(),
171            },
172            MerchantRef {
173                merchant_id: "merchant-123".to_string(),
174                legal_name: "Merchant Example LLC".to_string(),
175                display_name: Some("Merchant Example".to_string()),
176                statement_descriptor: None,
177                country_code: Some("US".to_string()),
178                website: Some("https://merchant.example".to_string()),
179                extensions: ProtocolExtensions::default(),
180            },
181            CommerceMode::HumanPresent,
182            Cart {
183                cart_id: Some("cart-1".to_string()),
184                lines: vec![CartLine {
185                    line_id: "line-1".to_string(),
186                    merchant_sku: Some("sku-1".to_string()),
187                    title: "Widget".to_string(),
188                    quantity: 1,
189                    unit_price: Money::new("USD", 2_500, 2),
190                    total_price: Money::new("USD", 2_500, 2),
191                    product_class: Some("widgets".to_string()),
192                    extensions: ProtocolExtensions::default(),
193                }],
194                subtotal: Some(Money::new("USD", 2_500, 2)),
195                adjustments: Vec::new(),
196                total: Money::new("USD", 2_500, 2),
197                affiliate_attribution: None,
198                extensions: ProtocolExtensions::default(),
199            },
200            created_at,
201        );
202        record.session_identity = Some(adk_core::AdkIdentity::new(
203            adk_core::AppName::try_from("commerce-app").unwrap(),
204            adk_core::UserId::try_from("alice").unwrap(),
205            adk_core::SessionId::try_from("session-123").unwrap(),
206        ));
207        record
208    }
209
210    #[test]
211    fn request_builder_replaces_scopes_and_metadata() {
212        let request = AuthenticatedPaymentRequest::new("alice")
213            .with_session_id("session-123")
214            .with_tenant_id("tenant-1")
215            .with_scopes(["payments:checkout:update"])
216            .with_metadata("channel", json!("agent"));
217
218        assert_eq!(request.user_id, "alice");
219        assert_eq!(request.session_id.as_deref(), Some("session-123"));
220        assert_eq!(request.tenant_id.as_deref(), Some("tenant-1"));
221        assert_eq!(request.scopes, vec!["payments:checkout:update".to_string()]);
222        assert_eq!(request.metadata.get("channel"), Some(&json!("agent")));
223    }
224
225    #[test]
226    fn binding_check_rejects_tenant_rebinding() {
227        let record = sample_transaction();
228        let err = AuthenticatedPaymentRequest::new("alice")
229            .with_session_id("session-123")
230            .with_tenant_id("tenant-2")
231            .assert_transaction_binding(&record)
232            .unwrap_err();
233
234        match err {
235            PaymentsAuthError::IdentityConflict { transaction_id, binding, expected, actual } => {
236                assert_eq!(transaction_id, "tx-tenant");
237                assert_eq!(binding, "tenant_id");
238                assert_eq!(expected, "tenant-1");
239                assert_eq!(actual, "tenant-2");
240            }
241            other => panic!("unexpected auth error: {other}"),
242        }
243    }
244}