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//! - Identity token support for authentication without mTLS (except initial issuance)
16//! - Bearer token authentication using identity tokens
17
18use serde::{Deserialize, Serialize};
19use thiserror::Error;
20
21use hessra_config::{HessraConfig, Protocol};
22
23// Error type for the API client
24#[derive(Error, Debug)]
25pub enum ApiError {
26    #[error("HTTP client error: {0}")]
27    HttpClient(#[from] reqwest::Error),
28
29    #[error("SSL configuration error: {0}")]
30    SslConfig(String),
31
32    #[error("Invalid response: {0}")]
33    InvalidResponse(String),
34
35    #[error("Token request error: {0}")]
36    TokenRequest(String),
37
38    #[error("Token verification error: {0}")]
39    TokenVerification(String),
40
41    #[error("Service chain error: {0}")]
42    ServiceChain(String),
43
44    #[error("Internal error: {0}")]
45    Internal(String),
46
47    #[error("Signoff failed: {0}")]
48    SignoffFailed(String),
49
50    #[error("Missing signoff configuration for service: {0}")]
51    MissingSignoffConfig(String),
52
53    #[error("Invalid signoff response from {service}: {reason}")]
54    InvalidSignoffResponse { service: String, reason: String },
55
56    #[error("Signoff collection incomplete: {missing_signoffs} signoffs remaining")]
57    IncompleteSignoffs { missing_signoffs: usize },
58}
59
60// Request and response structures
61/// Request payload for requesting an authorization token
62#[derive(Serialize, Deserialize)]
63pub struct TokenRequest {
64    /// The resource identifier to request authorization for
65    pub resource: String,
66    /// The operation to request authorization for
67    pub operation: String,
68    /// Optional domain for domain-restricted identity token verification.
69    /// When provided, enables enhanced verification with ensure_subject_in_domain().
70    /// This parameter is used when the client is authenticating with a domain-restricted
71    /// identity token and wants the server to verify the subject is truly associated with the domain.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub domain: Option<String>,
74}
75
76/// Request payload for verifying an authorization token
77#[derive(Serialize, Deserialize)]
78pub struct VerifyTokenRequest {
79    /// The authorization token to verify
80    pub token: String,
81    /// The subject identifier to verify against
82    pub subject: String,
83    /// The resource identifier to verify authorization against
84    pub resource: String,
85    /// The operation to verify authorization for
86    pub operation: String,
87}
88
89/// Information about required signoffs for multi-party tokens
90#[derive(Serialize, Deserialize, Debug, Clone)]
91pub struct SignoffInfo {
92    pub component: String,
93    pub authorization_service: String,
94    pub public_key: String,
95}
96
97/// Request structure for token signing operations
98#[derive(Serialize, Deserialize, Debug, Clone)]
99pub struct SignTokenRequest {
100    pub token: String,
101    pub resource: String,
102    pub operation: String,
103}
104
105/// Response structure for token signing operations
106#[derive(Serialize, Deserialize, Debug, Clone)]
107pub struct SignTokenResponse {
108    pub response_msg: String,
109    pub signed_token: Option<String>,
110}
111
112/// Enhanced token response that may include pending signoffs
113#[derive(Serialize, Deserialize, Debug, Clone)]
114pub struct TokenResponse {
115    /// Response message from the server
116    pub response_msg: String,
117    /// The issued token, if successful
118    pub token: Option<String>,
119    /// Pending signoffs required for multi-party tokens
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub pending_signoffs: Option<Vec<SignoffInfo>>,
122}
123
124/// Response from a token verification operation
125#[derive(Serialize, Deserialize)]
126pub struct VerifyTokenResponse {
127    /// Response message from the server
128    pub response_msg: String,
129}
130
131/// Response from a public key request
132#[derive(Serialize, Deserialize)]
133pub struct PublicKeyResponse {
134    pub response_msg: String,
135    pub public_key: String,
136}
137
138/// Response from a CA certificate request
139#[derive(Serialize, Deserialize)]
140pub struct CaCertResponse {
141    pub response_msg: String,
142    pub ca_cert_pem: String,
143}
144
145/// Request payload for verifying a service chain token
146#[derive(Serialize, Deserialize)]
147pub struct VerifyServiceChainTokenRequest {
148    pub token: String,
149    pub subject: String,
150    pub resource: String,
151    pub component: Option<String>,
152}
153
154/// Request for minting a new identity token
155#[derive(Serialize, Deserialize)]
156pub struct IdentityTokenRequest {
157    /// Optional identifier - required for token-only auth, optional for mTLS
158    pub identifier: Option<String>,
159}
160
161/// Request for refreshing an existing identity token
162#[derive(Serialize, Deserialize)]
163pub struct RefreshIdentityTokenRequest {
164    /// The current identity token to refresh
165    pub current_token: String,
166    /// Optional identifier - required for token-only auth, optional for mTLS
167    pub identifier: Option<String>,
168}
169
170/// Response from identity token operations
171#[derive(Serialize, Deserialize, Debug, Clone)]
172pub struct IdentityTokenResponse {
173    /// Response message from the server
174    pub response_msg: String,
175    /// The issued identity token, if successful
176    pub token: Option<String>,
177    /// Time until expiration in seconds
178    pub expires_in: Option<u64>,
179    /// The identity contained in the token
180    pub identity: Option<String>,
181}
182
183/// Request for minting a new domain-restricted identity token
184#[derive(Serialize, Deserialize)]
185pub struct MintIdentityTokenRequest {
186    /// The subject identifier for the new identity token
187    pub subject: String,
188    /// Optional duration in seconds (server will use default if not provided)
189    pub duration: Option<u64>,
190}
191
192/// Response from minting a domain-restricted identity token
193#[derive(Serialize, Deserialize, Debug, Clone)]
194pub struct MintIdentityTokenResponse {
195    /// Response message from the server
196    pub response_msg: String,
197    /// The minted identity token, if successful
198    pub token: Option<String>,
199    /// Time until expiration in seconds
200    pub expires_in: Option<u64>,
201    /// The identity contained in the token
202    pub identity: Option<String>,
203}
204
205/// Base configuration for Hessra clients
206#[derive(Clone)]
207pub struct BaseConfig {
208    /// Base URL of the Hessra service (without protocol scheme)
209    pub base_url: String,
210    /// Optional port to connect to
211    pub port: Option<u16>,
212    /// Optional mTLS private key in PEM format (required for mTLS auth)
213    pub mtls_key: Option<String>,
214    /// Optional mTLS client certificate in PEM format (required for mTLS auth)
215    pub mtls_cert: Option<String>,
216    /// Server CA certificate in PEM format
217    pub server_ca: String,
218    /// Public key for token verification in PEM format
219    pub public_key: Option<String>,
220    /// Personal keypair for service chain attestation
221    pub personal_keypair: Option<String>,
222}
223
224impl BaseConfig {
225    /// Get the formatted base URL, with port if specified
226    pub fn get_base_url(&self) -> String {
227        match self.port {
228            Some(port) => format!("{}:{port}", self.base_url),
229            None => self.base_url.clone(),
230        }
231    }
232}
233
234/// HTTP/1.1 client implementation
235pub struct Http1Client {
236    /// Base configuration
237    config: BaseConfig,
238    /// reqwest HTTP client with mTLS configured
239    client: reqwest::Client,
240}
241
242impl Http1Client {
243    /// Create a new HTTP/1.1 client with the given configuration
244    pub fn new(config: BaseConfig) -> Result<Self, ApiError> {
245        // Parse the CA certificate chain (may contain root + intermediates + leaf)
246        let certs =
247            reqwest::Certificate::from_pem_bundle(config.server_ca.as_bytes()).map_err(|e| {
248                ApiError::SslConfig(format!("Failed to parse CA certificate chain: {e}"))
249            })?;
250
251        // Build the client with or without mTLS depending on configuration
252        let mut client_builder = reqwest::ClientBuilder::new().use_rustls_tls();
253
254        // Add all certificates from the chain as trusted roots
255        for cert in certs {
256            client_builder = client_builder.add_root_certificate(cert);
257        }
258
259        // Add mTLS identity if both cert and key are provided
260        if let (Some(cert), Some(key)) = (&config.mtls_cert, &config.mtls_key) {
261            let identity_str = format!("{cert}{key}");
262            let identity = reqwest::Identity::from_pem(identity_str.as_bytes()).map_err(|e| {
263                ApiError::SslConfig(format!(
264                    "Failed to create identity from certificate and key: {e}"
265                ))
266            })?;
267            client_builder = client_builder.identity(identity);
268        }
269
270        let client = client_builder
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 remote Hessra authorization 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            .json(request_body)
290            .send()
291            .await
292            .map_err(ApiError::HttpClient)?;
293
294        if !response.status().is_success() {
295            let status = response.status();
296            let error_text = response.text().await.unwrap_or_default();
297            return Err(ApiError::InvalidResponse(format!(
298                "HTTP error: {status} - {error_text}"
299            )));
300        }
301
302        let result = response
303            .json::<R>()
304            .await
305            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
306
307        Ok(result)
308    }
309
310    pub async fn send_request_with_auth<T, R>(
311        &self,
312        endpoint: &str,
313        request_body: &T,
314        auth_header: &str,
315    ) -> Result<R, ApiError>
316    where
317        T: Serialize,
318        R: for<'de> Deserialize<'de>,
319    {
320        let base_url = self.config.get_base_url();
321        let url = format!("https://{base_url}/{endpoint}");
322
323        let response = self
324            .client
325            .post(&url)
326            .header("Authorization", auth_header)
327            .json(request_body)
328            .send()
329            .await
330            .map_err(ApiError::HttpClient)?;
331
332        if !response.status().is_success() {
333            let status = response.status();
334            let error_text = response.text().await.unwrap_or_default();
335            return Err(ApiError::InvalidResponse(format!(
336                "HTTP error: {status} - {error_text}"
337            )));
338        }
339
340        let result = response
341            .json::<R>()
342            .await
343            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
344
345        Ok(result)
346    }
347}
348
349/// HTTP/3 client implementation (only available with the "http3" feature)
350#[cfg(feature = "http3")]
351pub struct Http3Client {
352    /// Base configuration
353    config: BaseConfig,
354    /// QUIC endpoint for HTTP/3 connections
355    client: reqwest::Client,
356}
357
358#[cfg(feature = "http3")]
359impl Http3Client {
360    /// Create a new HTTP/3 client with the given configuration
361    pub fn new(config: BaseConfig) -> Result<Self, ApiError> {
362        // Parse the CA certificate chain (may contain root + intermediates + leaf)
363        let certs =
364            reqwest::Certificate::from_pem_bundle(config.server_ca.as_bytes()).map_err(|e| {
365                ApiError::SslConfig(format!("Failed to parse CA certificate chain: {e}"))
366            })?;
367
368        // Build the client with or without mTLS depending on configuration
369        let mut client_builder = reqwest::ClientBuilder::new()
370            .use_rustls_tls()
371            .http3_prior_knowledge();
372
373        // Add all certificates from the chain as trusted roots
374        for cert in certs {
375            client_builder = client_builder.add_root_certificate(cert);
376        }
377
378        // Add mTLS identity if both cert and key are provided
379        if let (Some(cert), Some(key)) = (&config.mtls_cert, &config.mtls_key) {
380            let identity_str = format!("{}{}", cert, key);
381            let identity = reqwest::Identity::from_pem(identity_str.as_bytes()).map_err(|e| {
382                ApiError::SslConfig(format!(
383                    "Failed to create identity from certificate and key: {e}"
384                ))
385            })?;
386            client_builder = client_builder.identity(identity);
387        }
388
389        let client = client_builder
390            .build()
391            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
392
393        Ok(Self { config, client })
394    }
395
396    /// Send a request to the Hessra service
397    pub async fn send_request<T, R>(&self, endpoint: &str, request_body: &T) -> Result<R, ApiError>
398    where
399        T: Serialize,
400        R: for<'de> Deserialize<'de>,
401    {
402        let base_url = self.config.get_base_url();
403        let url = format!("https://{base_url}/{endpoint}");
404
405        let response = self
406            .client
407            .post(&url)
408            .json(request_body)
409            .send()
410            .await
411            .map_err(ApiError::HttpClient)?;
412
413        if !response.status().is_success() {
414            let status = response.status();
415            let error_text = response.text().await.unwrap_or_default();
416            return Err(ApiError::InvalidResponse(format!(
417                "HTTP error: {status} - {error_text}"
418            )));
419        }
420
421        let result = response
422            .json::<R>()
423            .await
424            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
425
426        Ok(result)
427    }
428
429    pub async fn send_request_with_auth<T, R>(
430        &self,
431        endpoint: &str,
432        request_body: &T,
433        auth_header: &str,
434    ) -> Result<R, ApiError>
435    where
436        T: Serialize,
437        R: for<'de> Deserialize<'de>,
438    {
439        let base_url = self.config.get_base_url();
440        let url = format!("https://{base_url}/{endpoint}");
441
442        let response = self
443            .client
444            .post(&url)
445            .header("Authorization", auth_header)
446            .json(request_body)
447            .send()
448            .await
449            .map_err(ApiError::HttpClient)?;
450
451        if !response.status().is_success() {
452            let status = response.status();
453            let error_text = response.text().await.unwrap_or_default();
454            return Err(ApiError::InvalidResponse(format!(
455                "HTTP error: {status} - {error_text}"
456            )));
457        }
458
459        let result = response
460            .json::<R>()
461            .await
462            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
463
464        Ok(result)
465    }
466}
467
468/// The main Hessra client type providing token request and verification
469pub enum HessraClient {
470    /// HTTP/1.1 client
471    Http1(Http1Client),
472    /// HTTP/3 client (only available with the "http3" feature)
473    #[cfg(feature = "http3")]
474    Http3(Http3Client),
475}
476
477/// Builder for creating Hessra clients
478pub struct HessraClientBuilder {
479    /// Base configuration being built
480    config: BaseConfig,
481    /// Protocol to use for the client
482    protocol: hessra_config::Protocol,
483}
484
485impl HessraClientBuilder {
486    /// Create a new client builder with default values
487    pub fn new() -> Self {
488        Self {
489            config: BaseConfig {
490                base_url: String::new(),
491                port: None,
492                mtls_key: None,
493                mtls_cert: None,
494                server_ca: String::new(),
495                public_key: None,
496                personal_keypair: None,
497            },
498            protocol: Protocol::Http1,
499        }
500    }
501
502    /// Create a client builder from a HessraConfig
503    pub fn from_config(mut self, config: &HessraConfig) -> Self {
504        self.config.base_url = config.base_url.clone();
505        self.config.port = config.port;
506        self.config.mtls_key = config.mtls_key.clone();
507        self.config.mtls_cert = config.mtls_cert.clone();
508        self.config.server_ca = config.server_ca.clone();
509        self.config.public_key = config.public_key.clone();
510        self.config.personal_keypair = config.personal_keypair.clone();
511        self.protocol = config.protocol.clone();
512        self
513    }
514
515    /// Set the base URL for the client, e.g. "test.hessra.net"
516    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
517        self.config.base_url = base_url.into();
518        self
519    }
520
521    /// Set the mTLS private key for the client
522    /// PEM formatted string
523    pub fn mtls_key(mut self, mtls_key: impl Into<String>) -> Self {
524        self.config.mtls_key = Some(mtls_key.into());
525        self
526    }
527
528    /// Set the mTLS certificate for the client
529    /// PEM formatted string
530    pub fn mtls_cert(mut self, mtls_cert: impl Into<String>) -> Self {
531        self.config.mtls_cert = Some(mtls_cert.into());
532        self
533    }
534
535    /// Set the server CA certificate for the client
536    /// PEM formatted string
537    pub fn server_ca(mut self, server_ca: impl Into<String>) -> Self {
538        self.config.server_ca = server_ca.into();
539        self
540    }
541
542    /// Set the port for the client
543    pub fn port(mut self, port: u16) -> Self {
544        self.config.port = Some(port);
545        self
546    }
547
548    /// Set the protocol for the client
549    pub fn protocol(mut self, protocol: Protocol) -> Self {
550        self.protocol = protocol;
551        self
552    }
553
554    /// Set the public key for token verification
555    /// PEM formatted string. note, this is JUST the public key, not the entire keypair.
556    pub fn public_key(mut self, public_key: impl Into<String>) -> Self {
557        self.config.public_key = Some(public_key.into());
558        self
559    }
560
561    /// Set the personal keypair for service chain attestation
562    /// PEM formatted string. note, this is the entire keypair
563    /// and needs to be kept secret.
564    pub fn personal_keypair(mut self, keypair: impl Into<String>) -> Self {
565        self.config.personal_keypair = Some(keypair.into());
566        self
567    }
568
569    /// Build the HTTP/1.1 client
570    fn build_http1(&self) -> Result<Http1Client, ApiError> {
571        Http1Client::new(self.config.clone())
572    }
573
574    /// Build the HTTP/3 client
575    #[cfg(feature = "http3")]
576    fn build_http3(&self) -> Result<Http3Client, ApiError> {
577        Http3Client::new(self.config.clone())
578    }
579
580    /// Build the client
581    pub fn build(self) -> Result<HessraClient, ApiError> {
582        match self.protocol {
583            Protocol::Http1 => Ok(HessraClient::Http1(self.build_http1()?)),
584            #[cfg(feature = "http3")]
585            Protocol::Http3 => Ok(HessraClient::Http3(self.build_http3()?)),
586            #[allow(unreachable_patterns)]
587            _ => Err(ApiError::Internal("Unsupported protocol".to_string())),
588        }
589    }
590}
591
592impl Default for HessraClientBuilder {
593    fn default() -> Self {
594        Self::new()
595    }
596}
597
598impl HessraClient {
599    /// Create a new client builder
600    pub fn builder() -> HessraClientBuilder {
601        HessraClientBuilder::new()
602    }
603
604    /// Fetch the public key from the Hessra service without creating a client
605    /// The public_key endpoint is available as both an authenticated and unauthenticated
606    /// request.
607    pub async fn fetch_public_key(
608        base_url: impl Into<String>,
609        port: Option<u16>,
610        server_ca: impl Into<String>,
611    ) -> Result<String, ApiError> {
612        let base_url = base_url.into();
613        let server_ca = server_ca.into();
614
615        // Create a regular reqwest client (no mTLS)
616        let cert_pem = server_ca.as_bytes();
617        let certs = reqwest::Certificate::from_pem_bundle(cert_pem)
618            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
619
620        let mut client_builder = reqwest::ClientBuilder::new().use_rustls_tls();
621        for cert in certs {
622            client_builder = client_builder.add_root_certificate(cert);
623        }
624
625        let client = client_builder
626            .build()
627            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
628
629        // Format the URL
630        let url = match port {
631            Some(port) => format!("https://{base_url}:{port}/public_key"),
632            None => format!("https://{base_url}/public_key"),
633        };
634
635        // Make the request
636        let response = client
637            .get(&url)
638            .send()
639            .await
640            .map_err(ApiError::HttpClient)?;
641
642        if !response.status().is_success() {
643            let status = response.status();
644            let error_text = response.text().await.unwrap_or_default();
645            return Err(ApiError::InvalidResponse(format!(
646                "HTTP error: {status} - {error_text}"
647            )));
648        }
649
650        // Parse the response
651        let result = response
652            .json::<PublicKeyResponse>()
653            .await
654            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
655
656        Ok(result.public_key)
657    }
658
659    /// Fetch the CA certificate from the Hessra service without authentication
660    ///
661    /// This function makes an unauthenticated request to the `/ca_cert` endpoint
662    /// to retrieve the server's CA certificate in PEM format. This is useful for
663    /// bootstrapping trust when setting up a new client.
664    ///
665    /// # Bootstrap Trust Considerations
666    ///
667    /// This function uses the system CA store for the initial connection. If the
668    /// server uses a self-signed certificate, consider using `fetch_ca_cert_insecure`
669    /// instead (with appropriate warnings to users).
670    pub async fn fetch_ca_cert(
671        base_url: impl Into<String>,
672        port: Option<u16>,
673    ) -> Result<String, ApiError> {
674        let base_url = base_url.into();
675
676        // Create a reqwest client using system CA store
677        let client = reqwest::ClientBuilder::new()
678            .use_rustls_tls()
679            .build()
680            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
681
682        // Format the URL
683        let url = match port {
684            Some(port) => format!("https://{base_url}:{port}/ca_cert"),
685            None => format!("https://{base_url}/ca_cert"),
686        };
687
688        // Make the request
689        let response = client
690            .get(&url)
691            .send()
692            .await
693            .map_err(ApiError::HttpClient)?;
694
695        if !response.status().is_success() {
696            let status = response.status();
697            let error_text = response.text().await.unwrap_or_default();
698            return Err(ApiError::InvalidResponse(format!(
699                "HTTP error: {status} - {error_text}"
700            )));
701        }
702
703        // Parse the response
704        let result = response
705            .json::<CaCertResponse>()
706            .await
707            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
708
709        // Validate it's a non-empty PEM certificate
710        if result.ca_cert_pem.is_empty() {
711            return Err(ApiError::InvalidResponse(
712                "Server returned empty CA certificate".to_string(),
713            ));
714        }
715
716        if !result.ca_cert_pem.contains("-----BEGIN CERTIFICATE-----") {
717            return Err(ApiError::InvalidResponse(
718                "Server returned invalid PEM format".to_string(),
719            ));
720        }
721
722        Ok(result.ca_cert_pem)
723    }
724
725    #[cfg(feature = "http3")]
726    pub async fn fetch_public_key_http3(
727        base_url: impl Into<String>,
728        port: Option<u16>,
729        server_ca: impl Into<String>,
730    ) -> Result<String, ApiError> {
731        let base_url = base_url.into();
732        let server_ca = server_ca.into();
733
734        // Create a regular reqwest client (no mTLS)
735        let cert_pem = server_ca.as_bytes();
736        let certs = reqwest::Certificate::from_pem_bundle(cert_pem)
737            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
738
739        let mut client_builder = reqwest::ClientBuilder::new()
740            .use_rustls_tls()
741            .http3_prior_knowledge();
742        for cert in certs {
743            client_builder = client_builder.add_root_certificate(cert);
744        }
745
746        let client = client_builder
747            .build()
748            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
749
750        // Format the URL
751        let url = match port {
752            Some(port) => format!("https://{base_url}:{port}/public_key"),
753            None => format!("https://{base_url}/public_key"),
754        };
755
756        // Make the request
757        let response = client
758            .get(&url)
759            .send()
760            .await
761            .map_err(ApiError::HttpClient)?;
762
763        if !response.status().is_success() {
764            let status = response.status();
765            let error_text = response.text().await.unwrap_or_default();
766            return Err(ApiError::InvalidResponse(format!(
767                "HTTP error: {status} - {error_text}"
768            )));
769        }
770
771        // Parse the response
772        let result = response
773            .json::<PublicKeyResponse>()
774            .await
775            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
776
777        Ok(result.public_key)
778    }
779
780    /// Request a token for a resource
781    /// Returns the full TokenResponse which may include pending signoffs for multi-party tokens
782    ///
783    /// # Arguments
784    /// * `resource` - The resource identifier to request authorization for
785    /// * `operation` - The operation to request authorization for
786    /// * `domain` - Optional domain for domain-restricted identity token verification
787    pub async fn request_token(
788        &self,
789        resource: String,
790        operation: String,
791        domain: Option<String>,
792    ) -> Result<TokenResponse, ApiError> {
793        let request = TokenRequest {
794            resource,
795            operation,
796            domain,
797        };
798
799        let response = match self {
800            HessraClient::Http1(client) => {
801                client
802                    .send_request::<_, TokenResponse>("request_token", &request)
803                    .await?
804            }
805            #[cfg(feature = "http3")]
806            HessraClient::Http3(client) => {
807                client
808                    .send_request::<_, TokenResponse>("request_token", &request)
809                    .await?
810            }
811        };
812
813        Ok(response)
814    }
815
816    /// Request a token for a resource using an identity token for authentication
817    /// The identity token will be sent in the Authorization header as a Bearer token
818    /// Returns the full TokenResponse which may include pending signoffs for multi-party tokens
819    ///
820    /// # Arguments
821    /// * `resource` - The resource identifier to request authorization for
822    /// * `operation` - The operation to request authorization for
823    /// * `identity_token` - The identity token to use for authentication
824    /// * `domain` - Optional domain for domain-restricted identity token verification
825    pub async fn request_token_with_identity(
826        &self,
827        resource: String,
828        operation: String,
829        identity_token: String,
830        domain: Option<String>,
831    ) -> Result<TokenResponse, ApiError> {
832        let request = TokenRequest {
833            resource,
834            operation,
835            domain,
836        };
837
838        let response = match self {
839            HessraClient::Http1(client) => {
840                client
841                    .send_request_with_auth::<_, TokenResponse>(
842                        "request_token",
843                        &request,
844                        &format!("Bearer {identity_token}"),
845                    )
846                    .await?
847            }
848            #[cfg(feature = "http3")]
849            HessraClient::Http3(client) => {
850                client
851                    .send_request_with_auth::<_, TokenResponse>(
852                        "request_token",
853                        &request,
854                        &format!("Bearer {identity_token}"),
855                    )
856                    .await?
857            }
858        };
859
860        Ok(response)
861    }
862
863    /// Request a token for a resource (legacy method)
864    /// This method returns just the token string for backward compatibility
865    pub async fn request_token_simple(
866        &self,
867        resource: String,
868        operation: String,
869    ) -> Result<String, ApiError> {
870        let response = self.request_token(resource, operation, None).await?;
871
872        match response.token {
873            Some(token) => Ok(token),
874            None => Err(ApiError::TokenRequest(format!(
875                "Failed to get token: {}",
876                response.response_msg
877            ))),
878        }
879    }
880
881    /// Verify a token for subject doing operation on resource.
882    /// This will verify the token using the remote authorization service API.
883    pub async fn verify_token(
884        &self,
885        token: String,
886        subject: String,
887        resource: String,
888        operation: String,
889    ) -> Result<String, ApiError> {
890        let request = VerifyTokenRequest {
891            token,
892            subject,
893            resource,
894            operation,
895        };
896
897        let response = match self {
898            HessraClient::Http1(client) => {
899                client
900                    .send_request::<_, VerifyTokenResponse>("verify_token", &request)
901                    .await?
902            }
903            #[cfg(feature = "http3")]
904            HessraClient::Http3(client) => {
905                client
906                    .send_request::<_, VerifyTokenResponse>("verify_token", &request)
907                    .await?
908            }
909        };
910
911        Ok(response.response_msg)
912    }
913
914    /// Verify a service chain token. If no component is provided,
915    /// the entire service chain will be used to verify the token.
916    /// If a component name is provided, the service chain up to and
917    /// excluding the component will be used to verify the token. This
918    /// is useful for a node in the middle of the service chain
919    /// verifying a token has been attested by all previous nodes.
920    pub async fn verify_service_chain_token(
921        &self,
922        token: String,
923        subject: String,
924        resource: String,
925        component: Option<String>,
926    ) -> Result<String, ApiError> {
927        let request = VerifyServiceChainTokenRequest {
928            token,
929            subject,
930            resource,
931            component,
932        };
933
934        let response = match self {
935            HessraClient::Http1(client) => {
936                client
937                    .send_request::<_, VerifyTokenResponse>("verify_service_chain_token", &request)
938                    .await?
939            }
940            #[cfg(feature = "http3")]
941            HessraClient::Http3(client) => {
942                client
943                    .send_request::<_, VerifyTokenResponse>("verify_service_chain_token", &request)
944                    .await?
945            }
946        };
947
948        Ok(response.response_msg)
949    }
950
951    /// Sign a multi-party token by calling an authorization service's signoff endpoint
952    pub async fn sign_token(
953        &self,
954        token: &str,
955        resource: &str,
956        operation: &str,
957    ) -> Result<SignTokenResponse, ApiError> {
958        let request = SignTokenRequest {
959            token: token.to_string(),
960            resource: resource.to_string(),
961            operation: operation.to_string(),
962        };
963
964        let response = match self {
965            HessraClient::Http1(client) => {
966                client
967                    .send_request::<_, SignTokenResponse>("sign_token", &request)
968                    .await?
969            }
970            #[cfg(feature = "http3")]
971            HessraClient::Http3(client) => {
972                client
973                    .send_request::<_, SignTokenResponse>("sign_token", &request)
974                    .await?
975            }
976        };
977
978        Ok(response)
979    }
980
981    /// Get the public key from the server
982    pub async fn get_public_key(&self) -> Result<String, ApiError> {
983        let url_path = "public_key";
984
985        let response = match self {
986            HessraClient::Http1(client) => {
987                // For this endpoint, we just need a GET request, not a POST with a body
988                let base_url = client.config.get_base_url();
989                let full_url = format!("https://{base_url}/{url_path}");
990
991                let response = client
992                    .client
993                    .get(&full_url)
994                    .send()
995                    .await
996                    .map_err(ApiError::HttpClient)?;
997
998                if !response.status().is_success() {
999                    let status = response.status();
1000                    let error_text = response.text().await.unwrap_or_default();
1001                    return Err(ApiError::InvalidResponse(format!(
1002                        "HTTP error: {status} - {error_text}"
1003                    )));
1004                }
1005
1006                response.json::<PublicKeyResponse>().await.map_err(|e| {
1007                    ApiError::InvalidResponse(format!("Failed to parse response: {e}"))
1008                })?
1009            }
1010            #[cfg(feature = "http3")]
1011            HessraClient::Http3(client) => {
1012                let base_url = client.config.get_base_url();
1013                let full_url = format!("https://{base_url}/{url_path}");
1014
1015                let response = client
1016                    .client
1017                    .get(&full_url)
1018                    .send()
1019                    .await
1020                    .map_err(ApiError::HttpClient)?;
1021
1022                if !response.status().is_success() {
1023                    let status = response.status();
1024                    let error_text = response.text().await.unwrap_or_default();
1025                    return Err(ApiError::InvalidResponse(format!(
1026                        "HTTP error: {status} - {error_text}"
1027                    )));
1028                }
1029
1030                response.json::<PublicKeyResponse>().await.map_err(|e| {
1031                    ApiError::InvalidResponse(format!("Failed to parse response: {e}"))
1032                })?
1033            }
1034        };
1035
1036        Ok(response.public_key)
1037    }
1038
1039    /// Request a new identity token from the authorization service
1040    ///
1041    /// This endpoint requires mTLS authentication as it's the initial issuance of an identity token.
1042    /// The identifier parameter is optional when using mTLS, as the identity can be derived from the client certificate.
1043    ///
1044    /// # Arguments
1045    /// * `identifier` - Optional identifier for the identity. Required for non-mTLS future requests, optional with mTLS.
1046    pub async fn request_identity_token(
1047        &self,
1048        identifier: Option<String>,
1049    ) -> Result<IdentityTokenResponse, ApiError> {
1050        let request = IdentityTokenRequest { identifier };
1051
1052        let response = match self {
1053            HessraClient::Http1(client) => {
1054                client
1055                    .send_request::<_, IdentityTokenResponse>("request_identity_token", &request)
1056                    .await?
1057            }
1058            #[cfg(feature = "http3")]
1059            HessraClient::Http3(client) => {
1060                client
1061                    .send_request::<_, IdentityTokenResponse>("request_identity_token", &request)
1062                    .await?
1063            }
1064        };
1065
1066        Ok(response)
1067    }
1068
1069    /// Refresh an existing identity token
1070    ///
1071    /// This endpoint can use either mTLS or the current identity token for authentication.
1072    /// When using identity token authentication (no mTLS), the identifier parameter is required.
1073    /// The current token will be validated and a new token with updated expiration will be issued.
1074    ///
1075    /// # Arguments
1076    /// * `current_token` - The existing identity token to refresh
1077    /// * `identifier` - Optional identifier. Required when not using mTLS authentication.
1078    pub async fn refresh_identity_token(
1079        &self,
1080        current_token: String,
1081        identifier: Option<String>,
1082    ) -> Result<IdentityTokenResponse, ApiError> {
1083        let request = RefreshIdentityTokenRequest {
1084            current_token,
1085            identifier,
1086        };
1087
1088        let response = match self {
1089            HessraClient::Http1(client) => {
1090                client
1091                    .send_request::<_, IdentityTokenResponse>("refresh_identity_token", &request)
1092                    .await?
1093            }
1094            #[cfg(feature = "http3")]
1095            HessraClient::Http3(client) => {
1096                client
1097                    .send_request::<_, IdentityTokenResponse>("refresh_identity_token", &request)
1098                    .await?
1099            }
1100        };
1101
1102        Ok(response)
1103    }
1104
1105    /// Mint a new domain-restricted identity token
1106    ///
1107    /// This endpoint requires mTLS authentication from a "realm" identity (one without domain restriction).
1108    /// The minted token will be restricted to the minting identity's domain and cannot mint further sub-identities.
1109    /// Permissions are determined by domain roles configured on the server.
1110    ///
1111    /// # Arguments
1112    /// * `subject` - The subject identifier for the new identity (e.g., "uri:urn:test:argo-cli1:user123")
1113    /// * `duration` - Optional duration in seconds. If None, server uses configured default.
1114    pub async fn mint_domain_restricted_identity_token(
1115        &self,
1116        subject: String,
1117        duration: Option<u64>,
1118    ) -> Result<MintIdentityTokenResponse, ApiError> {
1119        let request = MintIdentityTokenRequest { subject, duration };
1120
1121        let response = match self {
1122            HessraClient::Http1(client) => {
1123                client
1124                    .send_request::<_, MintIdentityTokenResponse>("mint_identity_token", &request)
1125                    .await?
1126            }
1127            #[cfg(feature = "http3")]
1128            HessraClient::Http3(client) => {
1129                client
1130                    .send_request::<_, MintIdentityTokenResponse>("mint_identity_token", &request)
1131                    .await?
1132            }
1133        };
1134
1135        Ok(response)
1136    }
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142
1143    // Test BaseConfig get_base_url method
1144    #[test]
1145    fn test_base_config_get_base_url_with_port() {
1146        let config = BaseConfig {
1147            base_url: "test.hessra.net".to_string(),
1148            port: Some(443),
1149            mtls_key: None,
1150            mtls_cert: None,
1151            server_ca: "".to_string(),
1152            public_key: None,
1153            personal_keypair: None,
1154        };
1155
1156        assert_eq!(config.get_base_url(), "test.hessra.net:443");
1157    }
1158
1159    #[test]
1160    fn test_base_config_get_base_url_without_port() {
1161        let config = BaseConfig {
1162            base_url: "test.hessra.net".to_string(),
1163            port: None,
1164            mtls_key: None,
1165            mtls_cert: None,
1166            server_ca: "".to_string(),
1167            public_key: None,
1168            personal_keypair: None,
1169        };
1170
1171        assert_eq!(config.get_base_url(), "test.hessra.net");
1172    }
1173
1174    // Test HessraClientBuilder methods
1175    #[test]
1176    fn test_client_builder_methods() {
1177        let builder = HessraClientBuilder::new()
1178            .base_url("test.hessra.net")
1179            .port(443)
1180            .protocol(Protocol::Http1)
1181            .mtls_cert("CERT")
1182            .mtls_key("KEY")
1183            .server_ca("CA")
1184            .public_key("PUBKEY")
1185            .personal_keypair("KEYPAIR");
1186
1187        assert_eq!(builder.config.base_url, "test.hessra.net");
1188        assert_eq!(builder.config.port, Some(443));
1189        assert_eq!(builder.config.mtls_cert, Some("CERT".to_string()));
1190        assert_eq!(builder.config.mtls_key, Some("KEY".to_string()));
1191        assert_eq!(builder.config.server_ca, "CA");
1192        assert_eq!(builder.config.public_key, Some("PUBKEY".to_string()));
1193        assert_eq!(builder.config.personal_keypair, Some("KEYPAIR".to_string()));
1194    }
1195}