Skip to main content

lexe_node_client/
credentials.rs

1//! Client credentials for authentication with Lexe services.
2
3use std::{fmt, str::FromStr, sync::Arc};
4
5use anyhow::{Context, anyhow};
6use base64::Engine;
7use lexe_api::auth::BearerAuthenticator;
8use lexe_common::{
9    api::{
10        auth::{BearerAuthToken, LexeScope},
11        revocable_clients::CreateRevocableClientResponse,
12        user::UserPk,
13    },
14    env::DeployEnv,
15    root_seed::RootSeed,
16};
17#[cfg(any(test, feature = "test-utils"))]
18use lexe_crypto::rng::FastRng;
19use lexe_crypto::{ed25519, rng::Crng};
20#[cfg(any(test, feature = "test-utils"))]
21use lexe_tls::shared_seed::certs::{
22    EphemeralIssuingCaCert, RevocableClientCert, RevocableIssuingCaCert,
23};
24use lexe_tls::{
25    rustls, shared_seed,
26    types::{LxCertificateDer, LxPrivatePkcs8KeyDer},
27};
28#[cfg(any(test, feature = "test-utils"))]
29use proptest::{
30    prelude::{Arbitrary, any},
31    strategy::{BoxedStrategy, Strategy},
32};
33use serde::{Deserialize, Serialize};
34
35/// Credentials used to authenticate with a Lexe user node.
36//
37// Required to connect to a user node via mTLS.
38pub enum Credentials {
39    /// Using a [`RootSeed`]. Ex: app.
40    RootSeed(RootSeed),
41    /// Using a revocable client cert. Ex: SDK sidecar.
42    ClientCredentials(ClientCredentials),
43}
44
45/// Borrowed version of [`Credentials`].
46#[derive(Copy, Clone)]
47pub enum CredentialsRef<'a> {
48    /// Using a [`RootSeed`]. Ex: app.
49    RootSeed(&'a RootSeed),
50    /// Using a revocable client cert. Ex: SDK sidecar.
51    ClientCredentials(&'a ClientCredentials),
52}
53
54/// All secrets required for an SDK client to authenticate with a user's node.
55/// Encoded as a base64 JSON blob for easy transport (e.g. via env var or
56/// config file).
57#[derive(Clone, Serialize, Deserialize)]
58#[cfg_attr(any(test, feature = "test-utils"), derive(Eq, PartialEq))]
59pub struct ClientCredentials {
60    /// The user public key.
61    ///
62    /// Always `Some(_)` if the credentials were created by `node-v0.8.11+`.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    // #[cfg_attr(
65    //     any(test, feature = "test-utils"),
66    //     proptest(strategy = "any::<UserPk>().prop_map(Some)")
67    // )]
68    pub user_pk: Option<UserPk>,
69    /// The hex-encoded client public key.
70    pub client_pk: ed25519::PublicKey,
71    /// The DER-encoded client key.
72    pub rev_client_key_der: LxPrivatePkcs8KeyDer,
73    /// The DER-encoded cert of the revocable client.
74    pub rev_client_cert_der: LxCertificateDer,
75    /// The DER-encoded cert of the ephemeral issuing CA.
76    pub eph_ca_cert_der: LxCertificateDer,
77    /// The long-lived [`LexeScope::GatewayProxy`] token for connecting to the
78    /// user's node. Always `Some` for user nodes.
79    // compat: Alias added in lexe-node-client-v0.1.16
80    #[serde(
81        rename = "lexe_auth_token",
82        alias = "gateway_proxy_token",
83        skip_serializing_if = "Option::is_none"
84    )]
85    pub gateway_proxy_token: Option<BearerAuthToken>,
86}
87
88// --- impl Credentials / CredentialsRef --- //
89
90impl Credentials {
91    pub fn as_ref(&self) -> CredentialsRef<'_> {
92        match self {
93            Credentials::RootSeed(root_seed) =>
94                CredentialsRef::RootSeed(root_seed),
95            Credentials::ClientCredentials(client_credentials) =>
96                CredentialsRef::ClientCredentials(client_credentials),
97        }
98    }
99}
100
101impl From<RootSeed> for Credentials {
102    fn from(root_seed: RootSeed) -> Self {
103        Credentials::RootSeed(root_seed)
104    }
105}
106
107impl From<ClientCredentials> for Credentials {
108    fn from(client_credentials: ClientCredentials) -> Self {
109        Credentials::ClientCredentials(client_credentials)
110    }
111}
112
113impl<'a> From<&'a RootSeed> for CredentialsRef<'a> {
114    fn from(root_seed: &'a RootSeed) -> Self {
115        CredentialsRef::RootSeed(root_seed)
116    }
117}
118
119impl<'a> From<&'a ClientCredentials> for CredentialsRef<'a> {
120    fn from(client_credentials: &'a ClientCredentials) -> Self {
121        CredentialsRef::ClientCredentials(client_credentials)
122    }
123}
124
125impl<'a> CredentialsRef<'a> {
126    /// Returns the user public key.
127    ///
128    /// Always `Some(_)` if the credentials were created by `node-v0.8.11+`.
129    pub fn user_pk(&self) -> Option<UserPk> {
130        match self {
131            Self::RootSeed(root_seed) => Some(root_seed.derive_user_pk()),
132            Self::ClientCredentials(cc) => cc.user_pk,
133        }
134    }
135
136    /// Create a [`BearerAuthenticator`] appropriate for the given credentials.
137    ///
138    /// Errors if these credentials are [`ClientCredentials`] and are missing a
139    /// [`LexeScope::GatewayProxy`] token.
140    pub fn bearer_authenticator(
141        &self,
142    ) -> anyhow::Result<Arc<BearerAuthenticator>> {
143        match self {
144            // A root seed can mint tokens of any scope on demand.
145            Self::RootSeed(root_seed) => Ok(Arc::new(
146                BearerAuthenticator::new(root_seed.derive_user_key_pair()),
147            )),
148            // A client credential holds a single long-lived `GatewayProxy`
149            // token, good only for connecting to the user's node.
150            Self::ClientCredentials(client_credentials) => {
151                let gateway_proxy_token =
152                    client_credentials.gateway_proxy_token.clone().context(
153                        "Cannot connect to note; exported client credentials \
154                         are missing a gateway proxy token.",
155                    )?;
156                Ok(Arc::new(BearerAuthenticator::new_static_token(
157                    gateway_proxy_token,
158                    LexeScope::GatewayProxy,
159                )))
160            }
161        }
162    }
163
164    /// Build a TLS client config appropriate for the given credentials.
165    pub fn tls_config(
166        &self,
167        rng: &mut impl Crng,
168        deploy_env: DeployEnv,
169    ) -> anyhow::Result<rustls::ClientConfig> {
170        match self {
171            Self::RootSeed(root_seed) =>
172                shared_seed::app_node_run_client_config(
173                    rng, deploy_env, root_seed,
174                )
175                .context("Failed to build RootSeed TLS client config"),
176            Self::ClientCredentials(client_credentials) =>
177                shared_seed::sdk_node_run_client_config(
178                    deploy_env,
179                    &client_credentials.eph_ca_cert_der,
180                    client_credentials.rev_client_cert_der.clone(),
181                    client_credentials.rev_client_key_der.clone(),
182                )
183                .context("Failed to build revocable client TLS config"),
184        }
185    }
186}
187
188// --- impl ClientCredentials --- //
189
190impl fmt::Debug for ClientCredentials {
191    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
192        f.write_str("ClientCredentials(..)")
193    }
194}
195
196impl FromStr for ClientCredentials {
197    type Err = anyhow::Error;
198
199    fn from_str(s: &str) -> Result<Self, Self::Err> {
200        Self::try_from_base64_blob(s)
201    }
202}
203
204impl From<CreateRevocableClientResponse> for ClientCredentials {
205    fn from(resp: CreateRevocableClientResponse) -> Self {
206        ClientCredentials {
207            user_pk: resp.user_pk,
208            client_pk: resp.pubkey,
209            rev_client_key_der: LxPrivatePkcs8KeyDer(
210                resp.rev_client_cert_key_der,
211            ),
212            rev_client_cert_der: LxCertificateDer(resp.rev_client_cert_der),
213            eph_ca_cert_der: LxCertificateDer(resp.eph_ca_cert_der),
214            gateway_proxy_token: resp.gateway_proxy_token,
215        }
216    }
217}
218
219impl ClientCredentials {
220    /// Encodes a [`ClientCredentials`] to a base64 blob using
221    /// [`base64::engine::general_purpose::STANDARD_NO_PAD`].
222    //
223    // We use `STANDARD_NO_PAD` because trailing `=`s cause problems with
224    // autocomplete on iPhone. For example, if the base64 string ends with:
225    //
226    // - `NzB2mIn0=`
227    // - `NzBm2In0=`
228    //
229    // the iPhone autocompletes it to the following respectively when pasted
230    // into iMessage, even if you 'tap away' to reject the suggestion:
231    //
232    // - `NzB2mIn0=120 secs`
233    // - `NzBm2In0=0 in`
234    pub fn to_base64_blob(&self) -> String {
235        let json_str =
236            serde_json::to_string(self).expect("Failed to JSON serialize");
237        base64::engine::general_purpose::STANDARD_NO_PAD
238            .encode(json_str.as_bytes())
239    }
240
241    /// Decodes a [`ClientCredentials`] from a base64 blob encoded with either
242    /// [`base64::engine::general_purpose::STANDARD`] or
243    /// [`base64::engine::general_purpose::STANDARD_NO_PAD`].
244    //
245    // NOTE: This function accepts `STANDARD` encodings because historical
246    // client credentials were encoded with the `STANDARD` engine until we
247    // discovered that iPhones interpret the trailing `=` as part of a unit
248    // conversion, resulting in unintended autocompletions.
249    pub fn try_from_base64_blob(s: &str) -> anyhow::Result<Self> {
250        let s = s.trim().trim_end_matches('=');
251        let bytes = base64::engine::general_purpose::STANDARD_NO_PAD
252            .decode(s)
253            .context("String is not valid base64")?;
254        let string =
255            String::from_utf8(bytes).context("String is not valid UTF-8")?;
256        let cc = serde_json::from_str::<ClientCredentials>(&string)
257            .context("Failed to deserialize")?;
258
259        // Check that the deserialized ClientCredentials are well formed;
260        // ensure that private and public keys are consistent.
261        let rev_client_keypair = ed25519::KeyPair::deserialize_pkcs8_der(
262            cc.rev_client_key_der.as_bytes(),
263        )
264        .map_err(|_| anyhow!("Client key is invalid or corrupted"))?;
265        if rev_client_keypair.public_key() != &cc.client_pk {
266            return Err(anyhow!("Client key does not match client public key"));
267        }
268
269        Ok(cc)
270    }
271}
272
273/// Arbitrary ClientCredentials should be self-consistent
274#[cfg(any(test, feature = "test-utils"))]
275impl Arbitrary for ClientCredentials {
276    type Parameters = ();
277    type Strategy = BoxedStrategy<ClientCredentials>;
278    fn arbitrary_with((): Self::Parameters) -> Self::Strategy {
279        let root_seed = any::<RootSeed>();
280        let rng = any::<FastRng>();
281        let gateway_proxy_token = any::<Option<BearerAuthToken>>();
282        let has_user_pk = any::<bool>();
283
284        (root_seed, rng, gateway_proxy_token, has_user_pk)
285            .prop_map(
286                |(root_seed, mut rng, gateway_proxy_token, has_user_pk)| {
287                    // Derive intermediaries
288                    let eph_ca_cert =
289                        EphemeralIssuingCaCert::from_root_seed(&root_seed);
290                    let rev_ca_cert =
291                        RevocableIssuingCaCert::from_root_seed(&root_seed);
292                    let rev_client_cert =
293                        RevocableClientCert::generate_from_rng(&mut rng);
294
295                    // Derive fields
296                    let user_pk =
297                        has_user_pk.then(|| root_seed.derive_user_pk());
298                    let client_pk = rev_client_cert.public_key().to_owned();
299                    let rev_client_key_der =
300                        rev_client_cert.serialize_key_der();
301                    let rev_client_cert_der = rev_client_cert
302                        .serialize_der_ca_signed(&rev_ca_cert)
303                        .unwrap();
304                    let eph_ca_cert_der =
305                        eph_ca_cert.serialize_der_self_signed().unwrap();
306
307                    ClientCredentials {
308                        user_pk,
309                        client_pk,
310                        rev_client_key_der,
311                        rev_client_cert_der,
312                        eph_ca_cert_der,
313                        gateway_proxy_token,
314                    }
315                },
316            )
317            .boxed()
318    }
319}
320
321#[cfg(test)]
322mod test {
323    use std::fs;
324
325    use lexe_common::{
326        byte_str::ByteStr,
327        test_utils::{arbitrary, snapshot},
328    };
329    use lexe_crypto::rng::FastRng;
330    use lexe_tls::shared_seed::certs::{
331        EphemeralIssuingCaCert, RevocableIssuingCaCert,
332    };
333    use proptest::{prelude::any, prop_assert_eq, proptest};
334
335    use super::*;
336
337    /// Tests [`ClientCredentials`] roundtrip to/from base64.
338    ///
339    /// We also test compatibility: client credentials encoded with the old
340    /// STANDARD engine can be decoded with the new try_from_base64_blob method
341    /// which should accept both STANDARD and STANDARD_NO_PAD.
342    #[test]
343    fn prop_client_credentials_base64_roundtrip() {
344        proptest!(|(creds1 in proptest::prelude::any::<ClientCredentials>())| {
345            // Encode using `to_base64_blob` (STANDARD_NO_PAD).
346            // Decode using `try_from_base64_blob`.
347            {
348                let new_base64_blob = creds1.to_base64_blob();
349
350                let creds2 =
351                    ClientCredentials::try_from_base64_blob(&new_base64_blob)
352                        .expect("Failed to decode from new format");
353
354                prop_assert_eq!(&creds1, &creds2);
355            }
356
357            // Compatibility test:
358            // Encode using the engine used by old clients (STANDARD).
359            // Decode using `try_from_base64_blob`.
360            {
361                let json_str = serde_json::to_string(&creds1)
362                    .expect("Failed to JSON serialize");
363                let old_base64_blob = base64::engine::general_purpose::STANDARD
364                    .encode(json_str.as_bytes());
365                let creds2 =
366                    ClientCredentials::try_from_base64_blob(&old_base64_blob)
367                        .expect("Failed to decode from old format");
368
369                prop_assert_eq!(&creds1, &creds2);
370            }
371        });
372    }
373
374    /// Tests that the `STANDARD_NO_PAD` engine can decode any base64 string
375    /// encoded with the `STANDARD` engine after removing trailing `=`s.
376    #[test]
377    fn prop_base64_pad_to_no_pad_compat() {
378        proptest!(|(bytes1 in any::<Vec<u8>>())| {
379            let string =
380                base64::engine::general_purpose::STANDARD.encode(&bytes1);
381            let trimmed = string.trim_end_matches('=');
382            let bytes2 = base64::engine::general_purpose::STANDARD_NO_PAD
383                .decode(trimmed)
384                .expect("Failed to decode base64");
385            prop_assert_eq!(bytes1, bytes2);
386        })
387    }
388
389    #[test]
390    fn test_client_auth_encoding() {
391        let mut rng = FastRng::from_u64(202505121546);
392        let root_seed = RootSeed::from_rng(&mut rng);
393
394        let user_pk = root_seed.derive_user_pk();
395
396        let eph_ca_cert = EphemeralIssuingCaCert::from_root_seed(&root_seed);
397        let eph_ca_cert_der = eph_ca_cert.serialize_der_self_signed().unwrap();
398
399        let rev_ca_cert = RevocableIssuingCaCert::from_root_seed(&root_seed);
400
401        let rev_client_cert = RevocableClientCert::generate_from_rng(&mut rng);
402        let rev_client_cert_der = rev_client_cert
403            .serialize_der_ca_signed(&rev_ca_cert)
404            .unwrap();
405        let rev_client_key_der = rev_client_cert.serialize_key_der();
406        let client_pk = rev_client_cert.public_key();
407
408        let credentials = ClientCredentials {
409            user_pk: Some(user_pk),
410            client_pk: *client_pk,
411            rev_client_key_der,
412            rev_client_cert_der,
413            eph_ca_cert_der,
414            gateway_proxy_token: Some(BearerAuthToken(ByteStr::from_static(
415                "9dTCUvC8y7qcNyUbqynz3nwIQQHbQqPVKeMhXUj1Afr-vgj9E217_2tCS1IQM7LFqfBUC8Ec9fcb-dQiCRy6ot2FN-kR60edRFJUztAa2Rxao1Q0BS1s6vE8grgfhMYIAJDLMWgAAAAASE4zaAAAAABpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaWlpaQE",
416            ))),
417        };
418
419        let credentials_str = credentials.to_base64_blob();
420
421        // let json_len = serde_json::to_string(&credentials).unwrap().len();
422        // let base64_len = credentials_str.len();
423        // println!("json: {json_len}, base64: {base64_len}");
424
425        // json: 2259 B, base64(json): 3012 B
426        let expected_str = "eyJ1c2VyX3BrIjoiNmZkNzY0MTU2OTMwNTA5ZmFkNTM2MWQzYjIyYjYxZjc1YWE5MWVkNjQwMjE1YjJjNDFjMmZmODZiMmJmYzQ3MiIsImNsaWVudF9wayI6IjcwODhhZjFmYzEyYWIwNGFkNmRkMTY1YmMzYTNjNWViMzA2MmI0MTFhMmY1NWExNjZiMGU0MDBiMzkwZmU0ZGIiLCJyZXZfY2xpZW50X2tleV9kZXIiOiIzMDUxMDIwMTAxMzAwNTA2MDMyYjY1NzAwNDIyMDQyMDBmNTgwZDM0NjFjNGVhMGIzNmI4MzZkNDUxYzFjMTk5ZWUzZTA2NDZhZDBkNjQyMzUzNzk3MzlkNjg2OTkyODk4MTIxMDA3MDg4YWYxZmMxMmFiMDRhZDZkZDE2NWJjM2EzYzVlYjMwNjJiNDExYTJmNTVhMTY2YjBlNDAwYjM5MGZlNGRiIiwicmV2X2NsaWVudF9jZXJ0X2RlciI6IjMwODIwMTgzMzA4MjAxMzVhMDAzMDIwMTAyMDIxNDQwYmVkYzU2ZDAzZDZiNTJmMjg0MmQ2NGRmOTBkMDJkNmRhMzZhNWIzMDA1MDYwMzJiNjU3MDMwNTYzMTBiMzAwOTA2MDM1NTA0MDYwYzAyNTU1MzMxMGIzMDA5MDYwMzU1MDQwODBjMDI0MzQxMzExMTMwMGYwNjAzNTUwNDBhMGMwODZjNjU3ODY1MmQ2MTcwNzAzMTI3MzAyNTA2MDM1NTA0MDMwYzFlNGM2NTc4NjUyMDcyNjU3NjZmNjM2MTYyNmM2NTIwNjk3MzczNzU2OTZlNjcyMDQzNDEyMDYzNjU3Mjc0MzAyMDE3MGQzNzM1MzAzMTMwMzEzMDMwMzAzMDMwMzA1YTE4MGYzNDMwMzkzNjMwMzEzMDMxMzAzMDMwMzAzMDMwNWEzMDUyMzEwYjMwMDkwNjAzNTUwNDA2MGMwMjU1NTMzMTBiMzAwOTA2MDM1NTA0MDgwYzAyNDM0MTMxMTEzMDBmMDYwMzU1MDQwYTBjMDg2YzY1Nzg2NTJkNjE3MDcwMzEyMzMwMjEwNjAzNTUwNDAzMGMxYTRjNjU3ODY1MjA3MjY1NzY2ZjYzNjE2MjZjNjUyMDYzNmM2OTY1NmU3NDIwNjM2NTcyNzQzMDJhMzAwNTA2MDMyYjY1NzAwMzIxMDA3MDg4YWYxZmMxMmFiMDRhZDZkZDE2NWJjM2EzYzVlYjMwNjJiNDExYTJmNTVhMTY2YjBlNDAwYjM5MGZlNGRiYTMxNzMwMTUzMDEzMDYwMzU1MWQxMTA0MGMzMDBhODIwODZjNjU3ODY1MmU2MTcwNzAzMDA1MDYwMzJiNjU3MDAzNDEwMDdiMTdiYzk1MzgyNjdiMzU0ZjA3MjZkODljYjFlYzMxMGIxMDJlNDIyYWI5Njk2Yjg3ZDlhZTcwMGNlZjJlODNjMTM2NmQwYWQxOTAzNWQ5ZTNlZDA0Y2Y1ZjdmMDVkZWY2OGE3MWRlMjEyYjg5ODM0NDc3OTQyYWU3NjNhMjBmIiwiZXBoX2NhX2NlcnRfZGVyIjoiMzA4MjAxYWUzMDgyMDE2MGEwMDMwMjAxMDIwMjE0MTBjZDVjOTk4OWY5NjUyMDk0OWUwZTlhYjRjZTRkYmUxNDc2NjcxMDMwMDUwNjAzMmI2NTcwMzA1MDMxMGIzMDA5MDYwMzU1MDQwNjBjMDI1NTUzMzEwYjMwMDkwNjAzNTUwNDA4MGMwMjQzNDEzMTExMzAwZjA2MDM1NTA0MGEwYzA4NmM2NTc4NjUyZDYxNzA3MDMxMjEzMDFmMDYwMzU1MDQwMzBjMTg0YzY1Nzg2NTIwNzM2ODYxNzI2NTY0MjA3MzY1NjU2NDIwNDM0MTIwNjM2NTcyNzQzMDIwMTcwZDM3MzUzMDMxMzAzMTMwMzAzMDMwMzAzMDVhMTgwZjM0MzAzOTM2MzAzMTMwMzEzMDMwMzAzMDMwMzA1YTMwNTAzMTBiMzAwOTA2MDM1NTA0MDYwYzAyNTU1MzMxMGIzMDA5MDYwMzU1MDQwODBjMDI0MzQxMzExMTMwMGYwNjAzNTUwNDBhMGMwODZjNjU3ODY1MmQ2MTcwNzAzMTIxMzAxZjA2MDM1NTA0MDMwYzE4NGM2NTc4NjUyMDczNjg2MTcyNjU2NDIwNzM2NTY1NjQyMDQzNDEyMDYzNjU3Mjc0MzAyYTMwMDUwNjAzMmI2NTcwMDMyMTAwZWZlOWNlMWFiY2FlYWJjZWY4ZWEyZjU0YTU2OTU1MGRjZWQ0YThmM2E4Y2JiMDRjZDk0NWQxYjRlMjQ1ZjY4N2EzNGEzMDQ4MzAxMzA2MDM1NTFkMTEwNDBjMzAwYTgyMDg2YzY1Nzg2NTJlNjE3MDcwMzAxZDA2MDM1NTFkMGUwNDE2MDQxNDkwY2Q1Yzk5ODlmOTY1MjA5NDllMGU5YWI0Y2U0ZGJlMTQ3NjY3MTAzMDEyMDYwMzU1MWQxMzAxMDFmZjA0MDgzMDA2MDEwMWZmMDIwMTAwMzAwNTA2MDMyYjY1NzAwMzQxMDAzNzI1NDI5ZjViY2E4MDU2MjFjMmIyZGM0NDU4MDJlZDIxY2FiMjQ2YjQ1YWQxMjFkZDJhNDMyZWZhMmY5M2VmNzI1ZWZhMTc4MmU2NDEwOGQyMjk4ZTg2OTRmNDY4NmNlZDk4Y2U5MjgwZWQ3NDlkMGFkNGI0NGE0YTFjZWUwZCIsImxleGVfYXV0aF90b2tlbiI6IjlkVENVdkM4eTdxY055VWJxeW56M253SVFRSGJRcVBWS2VNaFhVajFBZnItdmdqOUUyMTdfMnRDUzFJUU03TEZxZkJVQzhFYzlmY2ItZFFpQ1J5Nm90MkZOLWtSNjBlZFJGSlV6dEFhMlJ4YW8xUTBCUzFzNnZFOGdyZ2ZoTVlJQUpETE1XZ0FBQUFBU0U0emFBQUFBQUJwYVdscGFXbHBhV2xwYVdscGFXbHBhV2xwYVdscGFXbHBhV2xwYVdscGFRRSJ9";
427        assert_eq!(credentials_str, expected_str);
428
429        let credentials2 =
430            ClientCredentials::try_from_base64_blob(&credentials_str)
431                .expect("Failed to decode ClientAuth");
432        assert_eq!(credentials, credentials2);
433    }
434
435    /// Generate serialized `ClientCredentials` sample json data:
436    ///
437    /// ```bash
438    /// $ cargo test -p lexe-node-client --lib -- take_client_credentials_snapshot --ignored --nocapture
439    /// ```
440    #[test]
441    #[ignore]
442    fn take_client_credentials_snapshot() {
443        let mut rng = FastRng::from_u64(202512210138);
444        const N: usize = 3;
445
446        let samples: Vec<ClientCredentials> =
447            arbitrary::gen_values(&mut rng, any::<ClientCredentials>(), N);
448
449        for sample in samples {
450            println!("{}", serde_json::to_string(&sample).unwrap());
451        }
452    }
453
454    // NOTE: see `take_client_credentials_snapshot` to generate new sample data.
455    #[test]
456    fn client_credentials_deser_compat() {
457        let snapshot =
458            fs::read_to_string("test_data/client_credentials_snapshot.txt")
459                .unwrap();
460
461        for input in snapshot::parse_sample_data(&snapshot) {
462            let value1: ClientCredentials =
463                serde_json::from_str(input).unwrap();
464            let output = serde_json::to_string(&value1).unwrap();
465            let value2: ClientCredentials =
466                serde_json::from_str(&output).unwrap();
467            assert_eq!(value1, value2);
468        }
469    }
470}