Skip to main content

lexe_connect/
request.rs

1//! The credential request and its connection string.
2
3use std::{
4    collections::BTreeSet,
5    fmt::{self, Display},
6    str::FromStr,
7};
8
9use lexe_byte_array::ByteArray;
10use lexe_common::time::TimestampMs;
11#[cfg(feature = "crypto")]
12use lexe_crypto::hpke;
13use lexe_crypto::rng::{Crng, RngExt};
14use lexe_uri::Uri;
15
16#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
17pub enum RequestError {
18    #[error("Not a {} connection string", CredentialRequest::LEXE_BASE_URL)]
19    NotConnectUrl,
20    #[error("Unsupported LexeConnect version: {0}")]
21    UnsupportedVersion(String),
22    #[error("Missing param: {0}")]
23    Missing(&'static str),
24    #[error("Invalid param {0}: {1}")]
25    Invalid(&'static str, String),
26    #[error("Unsupported param: {0}")]
27    Unsupported(String),
28}
29
30/// A credential request, as carried by a connection string.
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct CredentialRequest {
33    /// Fresh per request; the response is HPKE-encrypted under it.
34    /// Required unless `delivery` is [`Delivery::Post`].
35    pub ephemeral_hpke_pubkey: Option<HpkePubkey>,
36    /// Fresh per request; echoed in the response so it can be matched.
37    pub one_time_secret: OneTimeSecret,
38    pub params: CredentialRequestParams,
39}
40
41/// The parts of a [`CredentialRequest`] that the REQUESTER chooses.
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct CredentialRequestParams {
44    pub delivery: Delivery,
45    /// The REQUESTER account being connected, in a form the user recognizes,
46    /// e.g. `@janedoe`. Shown on the approval screen and bound into the
47    /// response encryption, so a forwarded request can't be re-targeted.
48    pub account: Option<String>,
49    /// Echoed back verbatim in the response.
50    pub metadata: Option<String>,
51    /// The REQUESTER's own name, shown only for a verified REQUESTER; see
52    /// [`CredentialRequest::requester_branding`].
53    pub requester_name: Option<String>,
54    /// The REQUESTER's icon url, likewise.
55    pub requester_icon: Option<String>,
56    /// Granted exactly as requested, or the request is rejected. Scope and
57    /// permission names are opaque here; the WALLET validates them when it
58    /// creates the credential.
59    pub scopes: BTreeSet<String>,
60    /// Granted exactly as requested, or the request is rejected.
61    pub permissions: BTreeSet<String>,
62    /// Prefills the credential label; the user may change it.
63    pub label: Option<String>,
64    /// Prefills the credential expiration; the user may change it.
65    pub expires_at: Option<TimestampMs>,
66}
67
68impl CredentialRequestParams {
69    // Spec: Protocol params, `account` and `metadata`.
70    const MAX_ACCOUNT_LEN: usize = 64;
71    const MAX_METADATA_LEN: usize = 1024;
72    /// Matches the Lexe client label limit.
73    const MAX_LABEL_LEN: usize = 64;
74}
75
76/// Where the WALLET delivers the response.
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub enum Delivery {
79    /// Redirect the user to this uri, appending the encrypted response as a
80    /// `response` query param. Should be an `https://` app link.
81    Redirect(String),
82    /// POST the response to this `https://` url.
83    Post(String),
84    /// POST the encrypted response to this `https://` mailbox, from which
85    /// the REQUESTER polls it.
86    Mailbox(String),
87}
88
89/// The REQUESTER's ephemeral X25519 HPKE public key, rendered as 64 lowercase
90/// hex characters.
91//
92// Spec: Protocol params.
93#[derive(Copy, Clone, Eq, Hash, PartialEq)]
94pub struct HpkePubkey([u8; 32]);
95
96lexe_byte_array::impl_byte_array!(HpkePubkey, 32);
97lexe_byte_array::impl_fromstr_fromhex!(HpkePubkey, 32);
98lexe_byte_array::impl_debug_display_as_hex!(HpkePubkey);
99
100#[cfg(feature = "crypto")]
101impl HpkePubkey {
102    pub(crate) fn from_hpke(pk: &hpke::PublicKey) -> Self {
103        Self(pk.to_array())
104    }
105
106    pub(crate) fn to_hpke(self) -> hpke::PublicKey {
107        hpke::PublicKey::from_array(self.0)
108    }
109}
110
111/// A random per-request secret, rendered as 32 lowercase hex characters.
112//
113// Spec: Protocol params.
114#[derive(Copy, Clone, Eq, Hash, PartialEq)]
115pub struct OneTimeSecret([u8; 16]);
116
117lexe_byte_array::impl_byte_array!(OneTimeSecret, 16);
118lexe_byte_array::impl_fromstr_fromhex!(OneTimeSecret, 16);
119lexe_byte_array::impl_debug_display_as_hex!(OneTimeSecret);
120lexe_serde::impl_serde_hexstr_or_bytes!(OneTimeSecret);
121
122impl OneTimeSecret {
123    pub fn from_rng(rng: &mut impl Crng) -> Self {
124        Self(rng.gen_bytes())
125    }
126}
127
128impl CredentialRequest {
129    /// The protocol version this crate implements.
130    //
131    // Spec: Protocol params, `v`.
132    const VERSION: u32 = 1;
133    /// Lexe's connect url; every connection string starts with it.
134    //
135    // Spec: WALLET-defined parts.
136    pub const LEXE_BASE_URL: &str = "https://lexe.app/connect";
137
138    /// Parse a connection string. Unrecognized params are dropped.
139    pub fn parse(s: &str) -> Result<Self, RequestError> {
140        let is_connect_url = s
141            .strip_prefix(Self::LEXE_BASE_URL)
142            .is_some_and(|rest| rest.is_empty() || rest.starts_with('?'));
143        if !is_connect_url {
144            return Err(RequestError::NotConnectUrl);
145        }
146
147        let uri = Uri::parse(s)
148            .map_err(|e| RequestError::Invalid("uri", e.to_string()))?;
149        Self::parse_uri(uri)
150    }
151
152    /// Parse the params of a connection string.
153    //
154    // Spec: Protocol params and Credential params.
155    fn parse_uri(uri: Uri<'_>) -> Result<Self, RequestError> {
156        fn parse_param<T: FromStr>(
157            name: &'static str,
158            s: &str,
159        ) -> Result<T, RequestError>
160        where
161            T::Err: Display,
162        {
163            s.parse()
164                .map_err(|e: T::Err| RequestError::Invalid(name, e.to_string()))
165        }
166
167        fn parse_list(s: &str) -> BTreeSet<String> {
168            s.split(',').map(str::to_owned).collect()
169        }
170
171        let mut v = None;
172        let mut redirect_uri = None;
173        let mut post_url = None;
174        let mut mailbox_url = None;
175        let mut ephemeral_hpke_pubkey = None;
176        let mut one_time_secret = None;
177        let mut account = None;
178        let mut metadata = None;
179        let mut requester_name = None;
180        let mut requester_icon = None;
181        let mut scopes = BTreeSet::new();
182        let mut permissions = BTreeSet::new();
183        let mut label = None;
184        let mut expires_at = None;
185
186        // Later duplicates override earlier ones; unrecognized keys are
187        // dropped.
188        for param in uri.params {
189            let value = param.value;
190            match param.key.as_ref() {
191                "v" => v = Some(value.into_owned()),
192                "redirect_uri" => redirect_uri = Some(value.into_owned()),
193                "post_url" => post_url = Some(value.into_owned()),
194                "mailbox_url" => mailbox_url = Some(value.into_owned()),
195                "ephemeral_hpke_pubkey" =>
196                    ephemeral_hpke_pubkey =
197                        Some(parse_param("ephemeral_hpke_pubkey", &value)?),
198                "one_time_secret" =>
199                    one_time_secret =
200                        Some(parse_param("one_time_secret", &value)?),
201                "account" => account = Some(value.into_owned()),
202                "metadata" => metadata = Some(value.into_owned()),
203                "requester_name" => requester_name = Some(value.into_owned()),
204                "requester_icon" => requester_icon = Some(value.into_owned()),
205                "scopes" => scopes = parse_list(&value),
206                "permissions" => permissions = parse_list(&value),
207                "label" => label = Some(value.into_owned()),
208                "expires_at" =>
209                    expires_at = Some(parse_param("expires_at", &value)?),
210                // Budgets are planned. Some budget params are exact, so
211                // dropping them would grant a credential the REQUESTER
212                // didn't ask for.
213                key if key.starts_with("budget_") =>
214                    return Err(RequestError::Unsupported(key.to_owned())),
215                _ => (),
216            }
217        }
218
219        let version = v.ok_or(RequestError::Missing("v"))?;
220        if version != Self::VERSION.to_string() {
221            return Err(RequestError::UnsupportedVersion(version));
222        }
223
224        let delivery = match (redirect_uri, post_url, mailbox_url) {
225            (Some(uri), None, None) => Delivery::Redirect(uri),
226            (None, Some(url), None) => Delivery::Post(url),
227            (None, None, Some(url)) => Delivery::Mailbox(url),
228            (None, None, None) =>
229                return Err(RequestError::Missing(
230                    "redirect_uri, post_url, or mailbox_url",
231                )),
232            _ =>
233                return Err(RequestError::Invalid(
234                    "delivery",
235                    "Set exactly one of redirect_uri, post_url, mailbox_url"
236                        .into(),
237                )),
238        };
239
240        let one_time_secret =
241            one_time_secret.ok_or(RequestError::Missing("one_time_secret"))?;
242
243        let request = Self {
244            ephemeral_hpke_pubkey,
245            one_time_secret,
246            params: CredentialRequestParams {
247                delivery,
248                account,
249                metadata,
250                requester_name,
251                requester_icon,
252                scopes,
253                permissions,
254                label,
255                expires_at,
256            },
257        };
258        request.validate()?;
259
260        Ok(request)
261    }
262
263    /// Render as a uri: [`Self::LEXE_BASE_URL`] plus the set params, in spec
264    /// order. The inverse of [`Self::parse_uri`].
265    fn to_uri(&self) -> Uri<'_> {
266        let p = &self.params;
267        let (delivery_key, delivery_target) = match &p.delivery {
268            Delivery::Redirect(uri) => ("redirect_uri", uri.as_str()),
269            Delivery::Post(url) => ("post_url", url.as_str()),
270            Delivery::Mailbox(url) => ("mailbox_url", url.as_str()),
271        };
272        let join = |set: &BTreeSet<String>| {
273            (!set.is_empty()).then(|| {
274                set.iter().map(String::as_str).collect::<Vec<_>>().join(",")
275            })
276        };
277        let scopes = join(&p.scopes);
278        let permissions = join(&p.permissions);
279
280        let mut uri: Uri<'_> = Uri::parse(Self::LEXE_BASE_URL)
281            .expect("LEXE_BASE_URL is a valid uri");
282
283        uri.push_param("v", Self::VERSION.to_string());
284        uri.push_param(delivery_key, delivery_target);
285        if let Some(pk) = self.ephemeral_hpke_pubkey {
286            uri.push_param("ephemeral_hpke_pubkey", pk.to_string());
287        }
288        uri.push_param("one_time_secret", self.one_time_secret.to_string());
289        if let Some(account) = &p.account {
290            uri.push_param("account", account.as_str());
291        }
292        if let Some(metadata) = &p.metadata {
293            uri.push_param("metadata", metadata.as_str());
294        }
295        if let Some(name) = &p.requester_name {
296            uri.push_param("requester_name", name.as_str());
297        }
298        if let Some(icon) = &p.requester_icon {
299            uri.push_param("requester_icon", icon.as_str());
300        }
301        if let Some(scopes) = scopes {
302            uri.push_param("scopes", scopes);
303        }
304        if let Some(permissions) = permissions {
305            uri.push_param("permissions", permissions);
306        }
307        if let Some(label) = &p.label {
308            uri.push_param("label", label.as_str());
309        }
310        if let Some(expires_at) = p.expires_at {
311            uri.push_param("expires_at", expires_at.to_u64().to_string());
312        }
313
314        uri
315    }
316
317    /// Check the constraints on a request.
318    //
319    // Spec: Protocol params and Credential params.
320    pub(crate) fn validate(&self) -> Result<(), RequestError> {
321        fn https_host(url: &str) -> Option<String> {
322            let uri = Uri::parse(url).ok()?;
323            let host = uri.is_https().then(|| uri.host()).flatten()?;
324            Some(host.into_owned())
325        }
326
327        fn check_len(
328            name: &'static str,
329            value: Option<&str>,
330            max_len: usize,
331        ) -> Result<(), RequestError> {
332            match value {
333                Some(v) if v.len() > max_len => Err(RequestError::Invalid(
334                    name,
335                    format!("Exceeds {max_len} bytes"),
336                )),
337                _ => Ok(()),
338            }
339        }
340
341        let p = &self.params;
342        match &p.delivery {
343            Delivery::Redirect(uri) => {
344                if Uri::parse(uri).is_err() {
345                    return Err(RequestError::Invalid(
346                        "redirect_uri",
347                        "Not a uri".into(),
348                    ));
349                }
350                if uri.contains('#') {
351                    return Err(RequestError::Invalid(
352                        "redirect_uri",
353                        "Must not contain a fragment".into(),
354                    ));
355                }
356            }
357            Delivery::Post(url) if https_host(url).is_none() =>
358                return Err(RequestError::Invalid(
359                    "post_url",
360                    "Must be an https:// url".into(),
361                )),
362            Delivery::Mailbox(url) if https_host(url).is_none() =>
363                return Err(RequestError::Invalid(
364                    "mailbox_url",
365                    "Must be an https:// url".into(),
366                )),
367            Delivery::Post(_) | Delivery::Mailbox(_) => (),
368        }
369
370        if self.ephemeral_hpke_pubkey.is_none()
371            && !matches!(p.delivery, Delivery::Post(_))
372        {
373            return Err(RequestError::Missing("ephemeral_hpke_pubkey"));
374        }
375
376        check_len(
377            "account",
378            p.account.as_deref(),
379            CredentialRequestParams::MAX_ACCOUNT_LEN,
380        )?;
381        check_len(
382            "metadata",
383            p.metadata.as_deref(),
384            CredentialRequestParams::MAX_METADATA_LEN,
385        )?;
386        check_len(
387            "label",
388            p.label.as_deref(),
389            CredentialRequestParams::MAX_LABEL_LEN,
390        )?;
391
392        if p.scopes.is_empty() && p.permissions.is_empty() {
393            return Err(RequestError::Missing("scopes or permissions"));
394        }
395
396        Ok(())
397    }
398}
399
400/// Renders the connection string; the inverse of [`CredentialRequest::parse`].
401impl fmt::Display for CredentialRequest {
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        self.to_uri().fmt(f)
404    }
405}
406
407#[cfg(any(test, feature = "test-utils"))]
408mod arbitrary_impl {
409    use lexe_common::test_utils::arbitrary::any_bounded_string;
410    use proptest::{
411        arbitrary::{Arbitrary, any},
412        option, prop_oneof,
413        strategy::{BoxedStrategy, Strategy},
414        string::string_regex,
415    };
416
417    use super::*;
418
419    impl Arbitrary for CredentialRequest {
420        type Parameters = ();
421        type Strategy = BoxedStrategy<Self>;
422        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
423            let any_https_url = || {
424                string_regex("https://[a-z0-9]{1,12}\\.[a-z]{2,4}/[a-z]{0,8}")
425                    .unwrap()
426            };
427            let any_delivery = prop_oneof![
428                any_https_url().prop_map(Delivery::Redirect),
429                any_https_url().prop_map(Delivery::Post),
430                any_https_url().prop_map(Delivery::Mailbox),
431            ];
432            let any_names = || {
433                proptest::collection::btree_set(
434                    string_regex("[a-z_]{1,16}").unwrap(),
435                    0..4,
436                )
437            };
438            let any_grant = (any_names(), any_names()).prop_filter(
439                "Must request something",
440                |(scopes, permissions)| {
441                    !scopes.is_empty() || !permissions.is_empty()
442                },
443            );
444            (
445                any::<Option<HpkePubkey>>(),
446                any::<OneTimeSecret>(),
447                any_delivery,
448                option::of(any_bounded_string(
449                    CredentialRequestParams::MAX_ACCOUNT_LEN,
450                )),
451                option::of(any_bounded_string(
452                    CredentialRequestParams::MAX_METADATA_LEN,
453                )),
454                (
455                    option::of(any_bounded_string(64)),
456                    option::of(any_https_url()),
457                ),
458                any_grant,
459                option::of(any_bounded_string(
460                    CredentialRequestParams::MAX_LABEL_LEN,
461                )),
462                any::<Option<TimestampMs>>(),
463            )
464                .prop_map(
465                    |(
466                        mut ephemeral_hpke_pubkey,
467                        one_time_secret,
468                        delivery,
469                        account,
470                        metadata,
471                        (requester_name, requester_icon),
472                        (scopes, permissions),
473                        label,
474                        expires_at,
475                    )| {
476                        // Only plaintext `Post` may omit the pubkey.
477                        if !matches!(delivery, Delivery::Post(_)) {
478                            ephemeral_hpke_pubkey.get_or_insert_with(|| {
479                                HpkePubkey::from_array([7; 32])
480                            });
481                        }
482                        CredentialRequest {
483                            ephemeral_hpke_pubkey,
484                            one_time_secret,
485                            params: CredentialRequestParams {
486                                delivery,
487                                account,
488                                metadata,
489                                requester_name,
490                                requester_icon,
491                                scopes,
492                                permissions,
493                                label,
494                                expires_at,
495                            },
496                        }
497                    },
498                )
499                .boxed()
500        }
501    }
502
503    impl Arbitrary for HpkePubkey {
504        type Parameters = ();
505        type Strategy = BoxedStrategy<Self>;
506        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
507            any::<[u8; 32]>().prop_map(Self).boxed()
508        }
509    }
510
511    impl Arbitrary for OneTimeSecret {
512        type Parameters = ();
513        type Strategy = BoxedStrategy<Self>;
514        fn arbitrary_with(_args: Self::Parameters) -> Self::Strategy {
515            any::<[u8; 16]>().prop_map(Self).boxed()
516        }
517    }
518}
519
520#[cfg(test)]
521mod test {
522    use lexe_crypto::rng::SysRng;
523    use proptest::{arbitrary::any, prop_assert_eq, proptest};
524
525    use super::*;
526
527    #[test]
528    fn connection_string_roundtrip_prop() {
529        proptest!(|(request in any::<CredentialRequest>())| {
530            let parsed = CredentialRequest::parse(&request.to_string());
531            prop_assert_eq!(parsed, Ok(request));
532        });
533    }
534
535    fn names<const N: usize>(names: [&str; N]) -> BTreeSet<String> {
536        names.map(String::from).into()
537    }
538
539    pub(crate) fn sample_params(delivery: Delivery) -> CredentialRequestParams {
540        CredentialRequestParams {
541            delivery,
542            account: Some("@janedoe".into()),
543            metadata: Some("{\"k\":\"v&=+\"}".into()),
544            requester_name: None,
545            requester_icon: None,
546            scopes: names(["read_info", "receive"]),
547            permissions: names(["cancel_payment"]),
548            label: Some("BillSplit App".into()),
549            expires_at: Some(TimestampMs::from_millis(1821484800000).unwrap()),
550        }
551    }
552
553    fn sample_request(delivery: Delivery) -> CredentialRequest {
554        let mut rng = SysRng::new();
555        CredentialRequest {
556            ephemeral_hpke_pubkey: Some(HpkePubkey::from_array(
557                rng.gen_bytes(),
558            )),
559            one_time_secret: OneTimeSecret::from_rng(&mut rng),
560            params: sample_params(delivery),
561        }
562    }
563
564    #[test]
565    fn connection_string_round_trip() {
566        for delivery in [
567            Delivery::Redirect("https://billsplit.com/cb?e=1".into()),
568            Delivery::Redirect("myprotocol://cb".into()),
569            Delivery::Post("https://paygate.com/lexe".into()),
570            Delivery::Mailbox(crate::LEXE_MAILBOX_URL.into()),
571        ] {
572            let request = sample_request(delivery);
573            let s = request.to_string();
574            assert!(s.starts_with("https://lexe.app/connect?v=1&"), "{s}");
575            assert_eq!(CredentialRequest::parse(&s).unwrap(), request);
576        }
577
578        // Plaintext post: no pubkey, minimal params.
579        let request = CredentialRequest {
580            ephemeral_hpke_pubkey: None,
581            params: CredentialRequestParams {
582                account: None,
583                metadata: None,
584                label: None,
585                expires_at: None,
586                ..sample_params(Delivery::Post("https://paygate.com".into()))
587            },
588            ..sample_request(Delivery::Post("https://paygate.com".into()))
589        };
590        let s = request.to_string();
591        assert_eq!(CredentialRequest::parse(&s).unwrap(), request);
592    }
593
594    #[test]
595    fn parse_spec_example_and_unknown_params() {
596        let s = "https://lexe.app/connect?v=1\
597            &redirect_uri=https%3A%2F%2Fbillsplit.com%2Fcb\
598            &ephemeral_hpke_pubkey=4310ee97d88cc1f088a5576c77ab0cf5c3ac797f3d95139c6c84b5429c59662a\
599            &one_time_secret=000102030405060708090a0b0c0d0e0f\
600            &scopes=read_info,read_payments,receive\
601            &future_param=x";
602        let request = CredentialRequest::parse(s).unwrap();
603        assert_eq!(
604            request.params.scopes,
605            names(["read_info", "read_payments", "receive"])
606        );
607        assert_eq!(request.params.account, None);
608    }
609
610    #[test]
611    fn parse_errors() {
612        let base =
613            sample_request(Delivery::Redirect("https://a.com/cb".into()));
614        let ok = base.to_string();
615        let parse_err = |s: &str| CredentialRequest::parse(s).unwrap_err();
616
617        assert_eq!(
618            parse_err("https://lexe.app/other?v=1"),
619            RequestError::NotConnectUrl
620        );
621        assert_eq!(
622            parse_err(&ok.replace("v=1&", "v=2&")),
623            RequestError::UnsupportedVersion("2".into())
624        );
625        assert_eq!(
626            parse_err(&ok.replace("v=1&", "")),
627            RequestError::Missing("v")
628        );
629        assert_eq!(
630            parse_err(&format!("{ok}&post_url=https%3A%2F%2Fb.com")),
631            RequestError::Invalid(
632                "delivery",
633                "Set exactly one of redirect_uri, post_url, mailbox_url".into()
634            )
635        );
636        assert_eq!(
637            parse_err(&ok.replace(
638                "&scopes=read_info%2Creceive&permissions=cancel_payment",
639                ""
640            )),
641            RequestError::Missing("scopes or permissions")
642        );
643        assert!(matches!(
644            parse_err(&ok.replace("&one_time_secret=", "&one_time_secret=ab")),
645            RequestError::Invalid("one_time_secret", _)
646        ));
647        assert!(matches!(
648            parse_err(&ok.replace("&expires_at=", "&expires_at=-")),
649            RequestError::Invalid("expires_at", _)
650        ));
651        assert_eq!(
652            parse_err(&format!("{ok}&budget_limit=20")),
653            RequestError::Unsupported("budget_limit".into())
654        );
655
656        let with = |delivery: Delivery, pubkey: bool| {
657            let mut request = sample_request(delivery);
658            if !pubkey {
659                request.ephemeral_hpke_pubkey = None;
660            }
661            request.validate().unwrap_err()
662        };
663        assert_eq!(
664            with(Delivery::Redirect("https://a.com/cb#frag".into()), true),
665            RequestError::Invalid(
666                "redirect_uri",
667                "Must not contain a fragment".into()
668            )
669        );
670        assert_eq!(
671            with(Delivery::Redirect("no-scheme".into()), true),
672            RequestError::Invalid("redirect_uri", "Not a uri".into())
673        );
674        assert_eq!(
675            with(Delivery::Redirect("https://a.com/cb".into()), false),
676            RequestError::Missing("ephemeral_hpke_pubkey")
677        );
678        assert_eq!(
679            with(Delivery::Mailbox("https://m.com".into()), false),
680            RequestError::Missing("ephemeral_hpke_pubkey")
681        );
682        assert_eq!(
683            with(Delivery::Post("http://a.com".into()), true),
684            RequestError::Invalid("post_url", "Must be an https:// url".into())
685        );
686        assert_eq!(
687            with(Delivery::Mailbox("https://".into()), true),
688            RequestError::Invalid(
689                "mailbox_url",
690                "Must be an https:// url".into()
691            )
692        );
693
694        let mut request = base.clone();
695        request.params.account =
696            Some("x".repeat(CredentialRequestParams::MAX_ACCOUNT_LEN + 1));
697        assert_eq!(
698            request.validate().unwrap_err(),
699            RequestError::Invalid("account", "Exceeds 64 bytes".into())
700        );
701        let mut request = base;
702        request.params.metadata =
703            Some("x".repeat(CredentialRequestParams::MAX_METADATA_LEN + 1));
704        assert_eq!(
705            request.validate().unwrap_err(),
706            RequestError::Invalid("metadata", "Exceeds 1024 bytes".into())
707        );
708    }
709}