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