hessra_api/
lib.rs

1//! # Hessra API
2//!
3//! HTTP client for Hessra authentication services.
4//!
5//! This crate provides a client for making HTTP requests to the Hessra
6//! authorization service. It supports both HTTP/1.1 and HTTP/3 (as an optional feature)
7//! and implements the OpenAPI specification for the Hessra service.
8//!
9//! ## Features
10//!
11//! - HTTP/1.1 client for Hessra services
12//! - Optional HTTP/3 support
13//! - Implementation of all Hessra API endpoints
14//! - Mutual TLS (mTLS) for secure client authentication
15
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18
19use hessra_config::{HessraConfig, Protocol};
20
21// Error type for the API client
22#[derive(Error, Debug)]
23pub enum ApiError {
24    #[error("HTTP client error: {0}")]
25    HttpClient(#[from] reqwest::Error),
26
27    #[error("SSL configuration error: {0}")]
28    SslConfig(String),
29
30    #[error("Invalid response: {0}")]
31    InvalidResponse(String),
32
33    #[error("Token request error: {0}")]
34    TokenRequest(String),
35
36    #[error("Token verification error: {0}")]
37    TokenVerification(String),
38
39    #[error("Service chain error: {0}")]
40    ServiceChain(String),
41
42    #[error("Internal error: {0}")]
43    Internal(String),
44
45    #[error("Signoff failed: {0}")]
46    SignoffFailed(String),
47
48    #[error("Missing signoff configuration for service: {0}")]
49    MissingSignoffConfig(String),
50
51    #[error("Invalid signoff response from {service}: {reason}")]
52    InvalidSignoffResponse { service: String, reason: String },
53
54    #[error("Signoff collection incomplete: {missing_signoffs} signoffs remaining")]
55    IncompleteSignoffs { missing_signoffs: usize },
56}
57
58// Request and response structures
59/// Request payload for requesting an authorization token
60#[derive(Serialize, Deserialize)]
61pub struct TokenRequest {
62    /// The resource identifier to request authorization for
63    pub resource: String,
64    /// The operation to request authorization for
65    pub operation: String,
66}
67
68/// Request payload for verifying an authorization token
69#[derive(Serialize, Deserialize)]
70pub struct VerifyTokenRequest {
71    /// The authorization token to verify
72    pub token: String,
73    /// The subject identifier to verify against
74    pub subject: String,
75    /// The resource identifier to verify authorization against
76    pub resource: String,
77    /// The operation to verify authorization for
78    pub operation: String,
79}
80
81/// Information about required signoffs for multi-party tokens
82#[derive(Serialize, Deserialize, Debug, Clone)]
83pub struct SignoffInfo {
84    pub component: String,
85    pub authorization_service: String,
86    pub public_key: String,
87}
88
89/// Request structure for token signing operations
90#[derive(Serialize, Deserialize, Debug, Clone)]
91pub struct SignTokenRequest {
92    pub token: String,
93    pub resource: String,
94    pub operation: String,
95}
96
97/// Response structure for token signing operations
98#[derive(Serialize, Deserialize, Debug, Clone)]
99pub struct SignTokenResponse {
100    pub response_msg: String,
101    pub signed_token: Option<String>,
102}
103
104/// Enhanced token response that may include pending signoffs
105#[derive(Serialize, Deserialize, Debug, Clone)]
106pub struct TokenResponse {
107    /// Response message from the server
108    pub response_msg: String,
109    /// The issued token, if successful
110    pub token: Option<String>,
111    /// Pending signoffs required for multi-party tokens
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub pending_signoffs: Option<Vec<SignoffInfo>>,
114}
115
116/// Response from a token verification operation
117#[derive(Serialize, Deserialize)]
118pub struct VerifyTokenResponse {
119    /// Response message from the server
120    pub response_msg: String,
121}
122
123/// Response from a public key request
124#[derive(Serialize, Deserialize)]
125pub struct PublicKeyResponse {
126    pub response_msg: String,
127    pub public_key: String,
128}
129
130/// Request payload for verifying a service chain token
131#[derive(Serialize, Deserialize)]
132pub struct VerifyServiceChainTokenRequest {
133    pub token: String,
134    pub subject: String,
135    pub resource: String,
136    pub component: Option<String>,
137}
138
139/// Base configuration for Hessra clients
140#[derive(Clone)]
141pub struct BaseConfig {
142    /// Base URL of the Hessra service (without protocol scheme)
143    pub base_url: String,
144    /// Optional port to connect to
145    pub port: Option<u16>,
146    /// mTLS private key in PEM format
147    pub mtls_key: String,
148    /// mTLS client certificate in PEM format
149    pub mtls_cert: String,
150    /// Server CA certificate in PEM format
151    pub server_ca: String,
152    /// Public key for token verification in PEM format
153    pub public_key: Option<String>,
154    /// Personal keypair for service chain attestation
155    pub personal_keypair: Option<String>,
156}
157
158impl BaseConfig {
159    /// Get the formatted base URL, with port if specified
160    pub fn get_base_url(&self) -> String {
161        match self.port {
162            Some(port) => format!("{}:{port}", self.base_url),
163            None => self.base_url.clone(),
164        }
165    }
166}
167
168/// HTTP/1.1 client implementation
169pub struct Http1Client {
170    /// Base configuration
171    config: BaseConfig,
172    /// reqwest HTTP client with mTLS configured
173    client: reqwest::Client,
174}
175
176impl Http1Client {
177    /// Create a new HTTP/1.1 client with the given configuration
178    pub fn new(config: BaseConfig) -> Result<Self, ApiError> {
179        // First try the simple approach with rustls-tls
180        // Create identity from combined PEM certificate and key
181        let identity_str = format!("{}{}", config.mtls_cert, config.mtls_key);
182
183        let identity = reqwest::Identity::from_pem(identity_str.as_bytes()).map_err(|e| {
184            ApiError::SslConfig(format!(
185                "Failed to create identity from certificate and key: {e}"
186            ))
187        })?;
188
189        // Parse the CA certificate
190        let cert_der = reqwest::Certificate::from_pem(config.server_ca.as_bytes())
191            .map_err(|e| ApiError::SslConfig(format!("Failed to parse CA certificate: {e}")))?;
192
193        // Build the reqwest client with mTLS configuration
194        let client = reqwest::ClientBuilder::new()
195            .use_rustls_tls()
196            .identity(identity)
197            .add_root_certificate(cert_der)
198            .build()
199            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
200
201        Ok(Self { config, client })
202    }
203
204    /// Send a request to the remote Hessra authorization service
205    pub async fn send_request<T, R>(&self, endpoint: &str, request_body: &T) -> Result<R, ApiError>
206    where
207        T: Serialize,
208        R: for<'de> Deserialize<'de>,
209    {
210        let base_url = self.config.get_base_url();
211        let url = format!("https://{base_url}/{endpoint}");
212
213        let response = self
214            .client
215            .post(&url)
216            .json(request_body)
217            .send()
218            .await
219            .map_err(ApiError::HttpClient)?;
220
221        if !response.status().is_success() {
222            let status = response.status();
223            let error_text = response.text().await.unwrap_or_default();
224            return Err(ApiError::InvalidResponse(format!(
225                "HTTP error: {status} - {error_text}"
226            )));
227        }
228
229        let result = response
230            .json::<R>()
231            .await
232            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
233
234        Ok(result)
235    }
236}
237
238/// HTTP/3 client implementation (only available with the "http3" feature)
239#[cfg(feature = "http3")]
240pub struct Http3Client {
241    /// Base configuration
242    config: BaseConfig,
243    /// QUIC endpoint for HTTP/3 connections
244    client: reqwest::Client,
245}
246
247#[cfg(feature = "http3")]
248impl Http3Client {
249    /// Create a new HTTP/3 client with the given configuration
250    pub fn new(config: BaseConfig) -> Result<Self, ApiError> {
251        // First try the simple approach with rustls-tls
252        // Create identity from combined PEM certificate and key
253        let identity_str = format!("{}{}", config.mtls_cert, config.mtls_key);
254
255        let identity = reqwest::Identity::from_pem(identity_str.as_bytes()).map_err(|e| {
256            ApiError::SslConfig(format!(
257                "Failed to create identity from certificate and key: {e}"
258            ))
259        })?;
260
261        // Parse the CA certificate
262        let cert_der = reqwest::Certificate::from_pem(config.server_ca.as_bytes())
263            .map_err(|e| ApiError::SslConfig(format!("Failed to parse CA certificate: {e}")))?;
264
265        // Build the reqwest client with mTLS configuration
266        let client = reqwest::ClientBuilder::new()
267            .use_rustls_tls()
268            .http3_prior_knowledge()
269            .identity(identity)
270            .add_root_certificate(cert_der)
271            .build()
272            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
273
274        Ok(Self { config, client })
275    }
276
277    /// Send a request to the Hessra service
278    pub async fn send_request<T, R>(&self, endpoint: &str, request_body: &T) -> Result<R, ApiError>
279    where
280        T: Serialize,
281        R: for<'de> Deserialize<'de>,
282    {
283        let base_url = self.config.get_base_url();
284        let url = format!("https://{base_url}/{endpoint}");
285
286        let response = self
287            .client
288            .post(&url)
289            .version(http::Version::HTTP_3)
290            .json(request_body)
291            .send()
292            .await
293            .map_err(ApiError::HttpClient)?;
294
295        if !response.status().is_success() {
296            let status = response.status();
297            let error_text = response.text().await.unwrap_or_default();
298            return Err(ApiError::InvalidResponse(format!(
299                "HTTP error: {status} - {error_text}"
300            )));
301        }
302
303        let result = response
304            .json::<R>()
305            .await
306            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
307
308        Ok(result)
309    }
310}
311
312/// The main Hessra client type providing token request and verification
313pub enum HessraClient {
314    /// HTTP/1.1 client
315    Http1(Http1Client),
316    /// HTTP/3 client (only available with the "http3" feature)
317    #[cfg(feature = "http3")]
318    Http3(Http3Client),
319}
320
321/// Builder for creating Hessra clients
322pub struct HessraClientBuilder {
323    /// Base configuration being built
324    config: BaseConfig,
325    /// Protocol to use for the client
326    protocol: hessra_config::Protocol,
327}
328
329impl HessraClientBuilder {
330    /// Create a new client builder with default values
331    pub fn new() -> Self {
332        Self {
333            config: BaseConfig {
334                base_url: String::new(),
335                port: None,
336                mtls_key: String::new(),
337                mtls_cert: String::new(),
338                server_ca: String::new(),
339                public_key: None,
340                personal_keypair: None,
341            },
342            protocol: Protocol::Http1,
343        }
344    }
345
346    /// Create a client builder from a HessraConfig
347    pub fn from_config(mut self, config: &HessraConfig) -> Self {
348        self.config.base_url = config.base_url.clone();
349        self.config.port = config.port;
350        self.config.mtls_key = config.mtls_key.clone();
351        self.config.mtls_cert = config.mtls_cert.clone();
352        self.config.server_ca = config.server_ca.clone();
353        self.config.public_key = config.public_key.clone();
354        self.config.personal_keypair = config.personal_keypair.clone();
355        self.protocol = config.protocol.clone();
356        self
357    }
358
359    /// Set the base URL for the client, e.g. "test.hessra.net"
360    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
361        self.config.base_url = base_url.into();
362        self
363    }
364
365    /// Set the mTLS private key for the client
366    /// PEM formatted string
367    pub fn mtls_key(mut self, mtls_key: impl Into<String>) -> Self {
368        self.config.mtls_key = mtls_key.into();
369        self
370    }
371
372    /// Set the mTLS certificate for the client
373    /// PEM formatted string
374    pub fn mtls_cert(mut self, mtls_cert: impl Into<String>) -> Self {
375        self.config.mtls_cert = mtls_cert.into();
376        self
377    }
378
379    /// Set the server CA certificate for the client
380    /// PEM formatted string
381    pub fn server_ca(mut self, server_ca: impl Into<String>) -> Self {
382        self.config.server_ca = server_ca.into();
383        self
384    }
385
386    /// Set the port for the client
387    pub fn port(mut self, port: u16) -> Self {
388        self.config.port = Some(port);
389        self
390    }
391
392    /// Set the protocol for the client
393    pub fn protocol(mut self, protocol: Protocol) -> Self {
394        self.protocol = protocol;
395        self
396    }
397
398    /// Set the public key for token verification
399    /// PEM formatted string. note, this is JUST the public key, not the entire keypair.
400    pub fn public_key(mut self, public_key: impl Into<String>) -> Self {
401        self.config.public_key = Some(public_key.into());
402        self
403    }
404
405    /// Set the personal keypair for service chain attestation
406    /// PEM formatted string. note, this is the entire keypair
407    /// and needs to be kept secret.
408    pub fn personal_keypair(mut self, keypair: impl Into<String>) -> Self {
409        self.config.personal_keypair = Some(keypair.into());
410        self
411    }
412
413    /// Build the HTTP/1.1 client
414    fn build_http1(&self) -> Result<Http1Client, ApiError> {
415        Http1Client::new(self.config.clone())
416    }
417
418    /// Build the HTTP/3 client
419    #[cfg(feature = "http3")]
420    fn build_http3(&self) -> Result<Http3Client, ApiError> {
421        Http3Client::new(self.config.clone())
422    }
423
424    /// Build the client
425    pub fn build(self) -> Result<HessraClient, ApiError> {
426        match self.protocol {
427            Protocol::Http1 => Ok(HessraClient::Http1(self.build_http1()?)),
428            #[cfg(feature = "http3")]
429            Protocol::Http3 => Ok(HessraClient::Http3(self.build_http3()?)),
430            #[allow(unreachable_patterns)]
431            _ => Err(ApiError::Internal("Unsupported protocol".to_string())),
432        }
433    }
434}
435
436impl Default for HessraClientBuilder {
437    fn default() -> Self {
438        Self::new()
439    }
440}
441
442impl HessraClient {
443    /// Create a new client builder
444    pub fn builder() -> HessraClientBuilder {
445        HessraClientBuilder::new()
446    }
447
448    /// Fetch the public key from the Hessra service without creating a client
449    /// The public_key endpoint is available as both an authenticated and unauthenticated
450    /// request.
451    pub async fn fetch_public_key(
452        base_url: impl Into<String>,
453        port: Option<u16>,
454        server_ca: impl Into<String>,
455    ) -> Result<String, ApiError> {
456        let base_url = base_url.into();
457        let server_ca = server_ca.into();
458
459        // Create a regular reqwest client (no mTLS)
460        let cert_pem = server_ca.as_bytes();
461        let cert_der = reqwest::Certificate::from_pem(cert_pem)
462            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
463
464        let client = reqwest::ClientBuilder::new()
465            .use_rustls_tls()
466            .add_root_certificate(cert_der)
467            .build()
468            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
469
470        // Format the URL
471        let url = match port {
472            Some(port) => format!("https://{base_url}:{port}/public_key"),
473            None => format!("https://{base_url}/public_key"),
474        };
475
476        // Make the request
477        let response = client
478            .get(&url)
479            .send()
480            .await
481            .map_err(ApiError::HttpClient)?;
482
483        if !response.status().is_success() {
484            let status = response.status();
485            let error_text = response.text().await.unwrap_or_default();
486            return Err(ApiError::InvalidResponse(format!(
487                "HTTP error: {status} - {error_text}"
488            )));
489        }
490
491        // Parse the response
492        let result = response
493            .json::<PublicKeyResponse>()
494            .await
495            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
496
497        Ok(result.public_key)
498    }
499
500    #[cfg(feature = "http3")]
501    pub async fn fetch_public_key_http3(
502        base_url: impl Into<String>,
503        port: Option<u16>,
504        server_ca: impl Into<String>,
505    ) -> Result<String, ApiError> {
506        let base_url = base_url.into();
507        let server_ca = server_ca.into();
508
509        // Create a regular reqwest client (no mTLS)
510        let cert_pem = server_ca.as_bytes();
511        let cert_der = reqwest::Certificate::from_pem(cert_pem)
512            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
513
514        let client = reqwest::ClientBuilder::new()
515            .use_rustls_tls()
516            .add_root_certificate(cert_der)
517            .http3_prior_knowledge()
518            .build()
519            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
520
521        // Format the URL
522        let url = match port {
523            Some(port) => format!("https://{base_url}:{port}/public_key"),
524            None => format!("https://{base_url}/public_key"),
525        };
526
527        // Make the request
528        let response = client
529            .get(&url)
530            .version(http::Version::HTTP_3)
531            .send()
532            .await
533            .map_err(ApiError::HttpClient)?;
534
535        if !response.status().is_success() {
536            let status = response.status();
537            let error_text = response.text().await.unwrap_or_default();
538            return Err(ApiError::InvalidResponse(format!(
539                "HTTP error: {status} - {error_text}"
540            )));
541        }
542
543        // Parse the response
544        let result = response
545            .json::<PublicKeyResponse>()
546            .await
547            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
548
549        Ok(result.public_key)
550    }
551
552    /// Request a token for a resource
553    /// Returns the full TokenResponse which may include pending signoffs for multi-party tokens
554    pub async fn request_token(
555        &self,
556        resource: String,
557        operation: String,
558    ) -> Result<TokenResponse, ApiError> {
559        let request = TokenRequest {
560            resource,
561            operation,
562        };
563
564        let response = match self {
565            HessraClient::Http1(client) => {
566                client
567                    .send_request::<_, TokenResponse>("request_token", &request)
568                    .await?
569            }
570            #[cfg(feature = "http3")]
571            HessraClient::Http3(client) => {
572                client
573                    .send_request::<_, TokenResponse>("request_token", &request)
574                    .await?
575            }
576        };
577
578        Ok(response)
579    }
580
581    /// Request a token for a resource (legacy method)
582    /// This method returns just the token string for backward compatibility
583    pub async fn request_token_simple(
584        &self,
585        resource: String,
586        operation: String,
587    ) -> Result<String, ApiError> {
588        let response = self.request_token(resource, operation).await?;
589
590        match response.token {
591            Some(token) => Ok(token),
592            None => Err(ApiError::TokenRequest(format!(
593                "Failed to get token: {}",
594                response.response_msg
595            ))),
596        }
597    }
598
599    /// Verify a token for subject doing operation on resource.
600    /// This will verify the token using the remote authorization service API.
601    pub async fn verify_token(
602        &self,
603        token: String,
604        subject: String,
605        resource: String,
606        operation: String,
607    ) -> Result<String, ApiError> {
608        let request = VerifyTokenRequest {
609            token,
610            subject,
611            resource,
612            operation,
613        };
614
615        let response = match self {
616            HessraClient::Http1(client) => {
617                client
618                    .send_request::<_, VerifyTokenResponse>("verify_token", &request)
619                    .await?
620            }
621            #[cfg(feature = "http3")]
622            HessraClient::Http3(client) => {
623                client
624                    .send_request::<_, VerifyTokenResponse>("verify_token", &request)
625                    .await?
626            }
627        };
628
629        Ok(response.response_msg)
630    }
631
632    /// Verify a service chain token. If no component is provided,
633    /// the entire service chain will be used to verify the token.
634    /// If a component name is provided, the service chain up to and
635    /// excluding the component will be used to verify the token. This
636    /// is useful for a node in the middle of the service chain
637    /// verifying a token has been attested by all previous nodes.
638    pub async fn verify_service_chain_token(
639        &self,
640        token: String,
641        subject: String,
642        resource: String,
643        component: Option<String>,
644    ) -> Result<String, ApiError> {
645        let request = VerifyServiceChainTokenRequest {
646            token,
647            subject,
648            resource,
649            component,
650        };
651
652        let response = match self {
653            HessraClient::Http1(client) => {
654                client
655                    .send_request::<_, VerifyTokenResponse>("verify_service_chain_token", &request)
656                    .await?
657            }
658            #[cfg(feature = "http3")]
659            HessraClient::Http3(client) => {
660                client
661                    .send_request::<_, VerifyTokenResponse>("verify_service_chain_token", &request)
662                    .await?
663            }
664        };
665
666        Ok(response.response_msg)
667    }
668
669    /// Sign a multi-party token by calling an authorization service's signoff endpoint
670    pub async fn sign_token(
671        &self,
672        token: &str,
673        resource: &str,
674        operation: &str,
675    ) -> Result<SignTokenResponse, ApiError> {
676        let request = SignTokenRequest {
677            token: token.to_string(),
678            resource: resource.to_string(),
679            operation: operation.to_string(),
680        };
681
682        let response = match self {
683            HessraClient::Http1(client) => {
684                client
685                    .send_request::<_, SignTokenResponse>("sign_token", &request)
686                    .await?
687            }
688            #[cfg(feature = "http3")]
689            HessraClient::Http3(client) => {
690                client
691                    .send_request::<_, SignTokenResponse>("sign_token", &request)
692                    .await?
693            }
694        };
695
696        Ok(response)
697    }
698
699    /// Get the public key from the server
700    pub async fn get_public_key(&self) -> Result<String, ApiError> {
701        let url_path = "public_key";
702
703        let response = match self {
704            HessraClient::Http1(client) => {
705                // For this endpoint, we just need a GET request, not a POST with a body
706                let base_url = client.config.get_base_url();
707                let full_url = format!("https://{base_url}/{url_path}");
708
709                let response = client
710                    .client
711                    .get(&full_url)
712                    .send()
713                    .await
714                    .map_err(ApiError::HttpClient)?;
715
716                if !response.status().is_success() {
717                    let status = response.status();
718                    let error_text = response.text().await.unwrap_or_default();
719                    return Err(ApiError::InvalidResponse(format!(
720                        "HTTP error: {status} - {error_text}"
721                    )));
722                }
723
724                response.json::<PublicKeyResponse>().await.map_err(|e| {
725                    ApiError::InvalidResponse(format!("Failed to parse response: {e}"))
726                })?
727            }
728            #[cfg(feature = "http3")]
729            HessraClient::Http3(client) => {
730                let base_url = client.config.get_base_url();
731                let full_url = format!("https://{base_url}/{url_path}");
732
733                let response = client
734                    .client
735                    .get(&full_url)
736                    .version(http::Version::HTTP_3)
737                    .send()
738                    .await
739                    .map_err(ApiError::HttpClient)?;
740
741                if !response.status().is_success() {
742                    let status = response.status();
743                    let error_text = response.text().await.unwrap_or_default();
744                    return Err(ApiError::InvalidResponse(format!(
745                        "HTTP error: {status} - {error_text}"
746                    )));
747                }
748
749                response.json::<PublicKeyResponse>().await.map_err(|e| {
750                    ApiError::InvalidResponse(format!("Failed to parse response: {e}"))
751                })?
752            }
753        };
754
755        Ok(response.public_key)
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    // Test BaseConfig get_base_url method
764    #[test]
765    fn test_base_config_get_base_url_with_port() {
766        let config = BaseConfig {
767            base_url: "test.hessra.net".to_string(),
768            port: Some(443),
769            mtls_key: "".to_string(),
770            mtls_cert: "".to_string(),
771            server_ca: "".to_string(),
772            public_key: None,
773            personal_keypair: None,
774        };
775
776        assert_eq!(config.get_base_url(), "test.hessra.net:443");
777    }
778
779    #[test]
780    fn test_base_config_get_base_url_without_port() {
781        let config = BaseConfig {
782            base_url: "test.hessra.net".to_string(),
783            port: None,
784            mtls_key: "".to_string(),
785            mtls_cert: "".to_string(),
786            server_ca: "".to_string(),
787            public_key: None,
788            personal_keypair: None,
789        };
790
791        assert_eq!(config.get_base_url(), "test.hessra.net");
792    }
793
794    // Test HessraClientBuilder methods
795    #[test]
796    fn test_client_builder_methods() {
797        let builder = HessraClientBuilder::new()
798            .base_url("test.hessra.net")
799            .port(443)
800            .protocol(Protocol::Http1)
801            .mtls_cert("CERT")
802            .mtls_key("KEY")
803            .server_ca("CA")
804            .public_key("PUBKEY")
805            .personal_keypair("KEYPAIR");
806
807        assert_eq!(builder.config.base_url, "test.hessra.net");
808        assert_eq!(builder.config.port, Some(443));
809        assert_eq!(builder.config.mtls_cert, "CERT");
810        assert_eq!(builder.config.mtls_key, "KEY");
811        assert_eq!(builder.config.server_ca, "CA");
812        assert_eq!(builder.config.public_key, Some("PUBKEY".to_string()));
813        assert_eq!(builder.config.personal_keypair, Some("KEYPAIR".to_string()));
814    }
815}