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