Skip to main content

lexe_connect/
response.rs

1//! The credential response and its JSON form.
2
3use std::{collections::BTreeSet, convert::Infallible, fmt, str::FromStr};
4
5use lexe_common::time::TimestampMs;
6use serde::{Deserialize, Serialize, Serializer};
7use serde_with::DeserializeFromStr;
8
9use crate::request::OneTimeSecret;
10
11#[derive(Debug, thiserror::Error)]
12pub enum ResponseError {
13    #[error("Response is not valid JSON: {0}")]
14    Json(#[from] serde_json::Error),
15    #[error("Response must set exactly one of credential or error")]
16    Malformed,
17}
18
19/// The WALLET's response, delivered as plaintext JSON or sealed in a
20/// [`Blob`](crate::seal::Blob).
21//
22// Spec: Credential response.
23///
24/// This is the parsed, validated form; [`CredentialResponseWire`] is the JSON.
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct CredentialResponse {
27    pub result: CredentialResult,
28    /// The request's `one_time_secret`, echoed back.
29    pub one_time_secret: OneTimeSecret,
30    /// The request's `account`, echoed back if set.
31    pub account: Option<String>,
32    /// The request's `metadata`, echoed back if set.
33    pub metadata: Option<String>,
34}
35
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub enum CredentialResult {
38    Granted(Grant),
39    Error(CredentialError),
40}
41
42/// A granted credential, echoing the request's exact grant.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct Grant {
45    /// The client credential, in its base64 string form.
46    pub credential: String,
47    /// Exactly as requested.
48    pub scopes: BTreeSet<String>,
49    /// Exactly as requested.
50    pub permissions: BTreeSet<String>,
51    /// When the credential expires; `None` if never.
52    pub expires_at: Option<TimestampMs>,
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct CredentialError {
57    pub code: ErrorCode,
58    /// A human-readable description.
59    pub message: Option<String>,
60}
61
62/// Unrecognized codes parse as [`ErrorCode::Other`].
63//
64// Spec: Protocol fields, `error`.
65#[derive(Copy, Clone, Debug, Eq, PartialEq)]
66#[derive(DeserializeFromStr)]
67pub enum ErrorCode {
68    UserRejected,
69    Other,
70}
71
72/// The response JSON: one flat object whose success fields are set on a
73/// grant and whose `error` fields are set on an error. Unrecognized fields
74/// are ignored.
75//
76// Spec: Protocol fields and Credential fields.
77#[serde_with::skip_serializing_none]
78#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
79pub struct CredentialResponseWire {
80    pub credential: Option<String>,
81    pub error: Option<ErrorCode>,
82    pub error_message: Option<String>,
83    pub one_time_secret: OneTimeSecret,
84    pub account: Option<String>,
85    pub metadata: Option<String>,
86    pub scopes: Option<BTreeSet<String>>,
87    pub permissions: Option<BTreeSet<String>>,
88    pub expires_at: Option<TimestampMs>,
89}
90
91impl CredentialResponse {
92    /// Parse the response JSON, plaintext or freshly opened.
93    pub fn from_json(json: &[u8]) -> Result<Self, ResponseError> {
94        let wire = serde_json::from_slice::<CredentialResponseWire>(json)?;
95        Self::try_from_wire(wire)
96    }
97
98    pub fn try_from_wire(
99        wire: CredentialResponseWire,
100    ) -> Result<Self, ResponseError> {
101        let result = match (wire.credential, wire.error) {
102            (Some(credential), None) => CredentialResult::Granted(Grant {
103                credential,
104                scopes: wire.scopes.unwrap_or_default(),
105                permissions: wire.permissions.unwrap_or_default(),
106                expires_at: wire.expires_at,
107            }),
108            (None, Some(code)) => CredentialResult::Error(CredentialError {
109                code,
110                message: wire.error_message,
111            }),
112            _ => return Err(ResponseError::Malformed),
113        };
114
115        Ok(Self {
116            result,
117            one_time_secret: wire.one_time_secret,
118            account: wire.account,
119            metadata: wire.metadata,
120        })
121    }
122
123    /// The response JSON bytes.
124    pub fn to_json(self) -> Vec<u8> {
125        serde_json::to_vec(&CredentialResponseWire::from(self))
126            .expect("JSON serialization is infallible")
127    }
128}
129
130impl From<CredentialResponse> for CredentialResponseWire {
131    fn from(response: CredentialResponse) -> Self {
132        let mut wire = Self {
133            credential: None,
134            error: None,
135            error_message: None,
136            one_time_secret: response.one_time_secret,
137            account: response.account,
138            metadata: response.metadata,
139            scopes: None,
140            permissions: None,
141            expires_at: None,
142        };
143
144        match response.result {
145            CredentialResult::Granted(grant) => {
146                wire.credential = Some(grant.credential);
147                wire.scopes = Some(grant.scopes);
148                wire.permissions = Some(grant.permissions);
149                wire.expires_at = grant.expires_at;
150            }
151            CredentialResult::Error(error) => {
152                wire.error = Some(error.code);
153                wire.error_message = error.message;
154            }
155        }
156
157        wire
158    }
159}
160
161impl ErrorCode {
162    fn as_str(self) -> &'static str {
163        match self {
164            Self::UserRejected => "user_rejected",
165            Self::Other => "other",
166        }
167    }
168}
169
170impl Serialize for ErrorCode {
171    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
172        s.serialize_str(self.as_str())
173    }
174}
175
176impl fmt::Display for ErrorCode {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.write_str(self.as_str())
179    }
180}
181
182impl FromStr for ErrorCode {
183    type Err = Infallible;
184
185    fn from_str(s: &str) -> Result<Self, Self::Err> {
186        Ok(match s {
187            "user_rejected" => Self::UserRejected,
188            _ => Self::Other,
189        })
190    }
191}
192
193#[cfg(test)]
194mod test {
195    use super::*;
196
197    /// The spec's success example, `budget_*` fields included.
198    const SPEC_SUCCESS: &str = r#"{
199        "credential": "<client credential>",
200        "one_time_secret": "000102030405060708090a0b0c0d0e0f",
201        "account": "@janedoe",
202        "metadata": "<metadata echoed from the request>",
203        "scopes": ["read_info", "read_payments",  "receive", "spend"],
204        "permissions": ["cancel_payment"],
205        "expires_at": 1821484800000,
206        "budget_limit": "20",
207        "budget_currency": "usd",
208        "budget_period": "month",
209        "budget_period_multiple": 1,
210        "budget_first_reset": 1790838000000,
211        "budget_utc_offset_secs": -25200
212    }"#;
213
214    const SPEC_ERROR: &str = r#"{
215        "error": "user_rejected",
216        "error_message": "<human-readable message>",
217        "one_time_secret": "000102030405060708090a0b0c0d0e0f",
218        "account": "@janedoe",
219        "metadata": "<metadata echoed from the request>"
220    }"#;
221
222    #[test]
223    fn spec_examples() {
224        let from_json =
225            |json: &str| CredentialResponse::from_json(json.as_bytes());
226        let success = from_json(SPEC_SUCCESS).unwrap();
227        let CredentialResult::Granted(grant) = &success.result else {
228            panic!("Expected a grant");
229        };
230        assert_eq!(grant.credential, "<client credential>");
231        assert_eq!(
232            grant.scopes,
233            ["read_info", "read_payments", "receive", "spend"]
234                .map(String::from)
235                .into()
236        );
237        assert_eq!(grant.permissions, ["cancel_payment".to_owned()].into());
238        assert_eq!(
239            grant.expires_at,
240            Some(TimestampMs::from_millis(1821484800000).unwrap())
241        );
242        assert_eq!(success.account.as_deref(), Some("@janedoe"));
243
244        let error = from_json(SPEC_ERROR).unwrap();
245        let CredentialResult::Error(err) = &error.result else {
246            panic!("Expected an error");
247        };
248        assert_eq!(err.code, ErrorCode::UserRejected);
249        assert_eq!(err.message.as_deref(), Some("<human-readable message>"));
250
251        // Round trip through our own serialization, and unknown codes.
252        for resp in [success, error] {
253            let json = String::from_utf8(resp.clone().to_json()).unwrap();
254            assert_eq!(from_json(&json).unwrap(), resp);
255        }
256        let json = SPEC_ERROR.replace("user_rejected", "new_code");
257        let CredentialResult::Error(err) = from_json(&json).unwrap().result
258        else {
259            panic!("Expected an error");
260        };
261        assert_eq!(err.code, ErrorCode::Other);
262
263        // Exactly one of `credential` and `error` must be set.
264        let secret_only =
265            r#"{"one_time_secret": "000102030405060708090a0b0c0d0e0f"}"#;
266        assert!(matches!(
267            from_json(secret_only),
268            Err(ResponseError::Malformed)
269        ));
270        let both = SPEC_ERROR
271            .replace("\"error\":", "\"credential\": \"c\", \"error\":");
272        assert!(matches!(from_json(&both), Err(ResponseError::Malformed)));
273    }
274
275    #[test]
276    fn serialized_shape_is_flat() {
277        let resp = CredentialResponse {
278            result: CredentialResult::Error(CredentialError {
279                code: ErrorCode::Other,
280                message: None,
281            }),
282            one_time_secret: "000102030405060708090a0b0c0d0e0f"
283                .parse()
284                .unwrap(),
285            account: None,
286            metadata: None,
287        };
288        assert_eq!(
289            String::from_utf8(resp.to_json()).unwrap(),
290            r#"{"error":"other","one_time_secret":"000102030405060708090a0b0c0d0e0f"}"#
291        );
292    }
293}