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