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