Skip to main content

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/// Request to mint a stub token that requires prefix attestation before use.
272///
273/// Stub tokens are minted by a realm identity on behalf of a target identity
274/// within their domain. The token requires a trusted third party (identified by
275/// the prefix_attenuator_key) to add a prefix restriction before the token
276/// can be used.
277#[derive(Serialize, Deserialize, Debug, Clone)]
278pub struct StubTokenRequest {
279    /// The identity who will use this token (must be in minter's domain)
280    pub target_identity: String,
281    /// The resource the stub token grants access to
282    pub resource: String,
283    /// The operation allowed on the resource
284    pub operation: String,
285    /// Public key that will attest the prefix (format: "ed25519/..." or "secp256r1/...")
286    pub prefix_attenuator_key: String,
287    /// Optional token duration in seconds (defaults to minter's configured duration)
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub duration: Option<u64>,
290}
291
292/// Response from minting a stub token.
293#[derive(Serialize, Deserialize, Debug, Clone, Default)]
294pub struct StubTokenResponse {
295    /// Response message from the server
296    pub response_msg: String,
297    /// The stub token (requires prefix attestation before use)
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub token: Option<String>,
300    /// Duration until expiry in seconds
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub expires_in: Option<u64>,
303    /// The target identity encoded in the token
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub target_identity: Option<String>,
306    /// The prefix attenuator key that must attest this token
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub prefix_attenuator_key: Option<String>,
309}
310
311/// Base configuration for Hessra clients
312#[derive(Clone)]
313pub struct BaseConfig {
314    /// Base URL of the Hessra service (without protocol scheme)
315    pub base_url: String,
316    /// Optional port to connect to
317    pub port: Option<u16>,
318    /// Optional mTLS private key in PEM format (required for mTLS auth)
319    pub mtls_key: Option<String>,
320    /// Optional mTLS client certificate in PEM format (required for mTLS auth)
321    pub mtls_cert: Option<String>,
322    /// Server CA certificate in PEM format
323    pub server_ca: String,
324    /// Public key for token verification in PEM format
325    pub public_key: Option<String>,
326    /// Personal keypair for service chain attestation
327    pub personal_keypair: Option<String>,
328}
329
330impl BaseConfig {
331    /// Get the formatted base URL, with port if specified.
332    ///
333    /// Handles cases where base_url might already contain an embedded port.
334    /// If both an embedded port and self.port are present, self.port takes precedence.
335    pub fn get_base_url(&self) -> String {
336        // Parse the base_url to extract host and any embedded port
337        let (host, embedded_port) = parse_server_address(&self.base_url);
338
339        // Explicitly set port takes precedence, then embedded port
340        let resolved_port = self.port.or(embedded_port);
341
342        match resolved_port {
343            Some(port) => format!("{host}:{port}"),
344            None => host,
345        }
346    }
347}
348
349/// HTTP/1.1 client implementation
350pub struct Http1Client {
351    /// Base configuration
352    config: BaseConfig,
353    /// reqwest HTTP client with mTLS configured
354    client: reqwest::Client,
355}
356
357impl Http1Client {
358    /// Create a new HTTP/1.1 client with the given configuration
359    pub fn new(config: BaseConfig) -> Result<Self, ApiError> {
360        // Parse the CA certificate chain (may contain root + intermediates + leaf)
361        let certs =
362            reqwest::Certificate::from_pem_bundle(config.server_ca.as_bytes()).map_err(|e| {
363                ApiError::SslConfig(format!("Failed to parse CA certificate chain: {e}"))
364            })?;
365
366        // Build the client with or without mTLS depending on configuration
367        let mut client_builder = reqwest::ClientBuilder::new().use_rustls_tls();
368
369        // Add all certificates from the chain as trusted roots
370        for cert in certs {
371            client_builder = client_builder.add_root_certificate(cert);
372        }
373
374        // Add mTLS identity if both cert and key are provided
375        if let (Some(cert), Some(key)) = (&config.mtls_cert, &config.mtls_key) {
376            let identity_str = format!("{cert}{key}");
377            let identity = reqwest::Identity::from_pem(identity_str.as_bytes()).map_err(|e| {
378                ApiError::SslConfig(format!(
379                    "Failed to create identity from certificate and key: {e}"
380                ))
381            })?;
382            client_builder = client_builder.identity(identity);
383        }
384
385        let client = client_builder
386            .build()
387            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
388
389        Ok(Self { config, client })
390    }
391
392    /// Send a request to the remote Hessra authorization service
393    pub async fn send_request<T, R>(&self, endpoint: &str, request_body: &T) -> Result<R, ApiError>
394    where
395        T: Serialize,
396        R: for<'de> Deserialize<'de>,
397    {
398        let base_url = self.config.get_base_url();
399        let url = format!("https://{base_url}/{endpoint}");
400
401        let response = self
402            .client
403            .post(&url)
404            .json(request_body)
405            .send()
406            .await
407            .map_err(ApiError::HttpClient)?;
408
409        if !response.status().is_success() {
410            let status = response.status();
411            let error_text = response.text().await.unwrap_or_default();
412            return Err(ApiError::InvalidResponse(format!(
413                "HTTP error: {status} - {error_text}"
414            )));
415        }
416
417        let result = response
418            .json::<R>()
419            .await
420            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
421
422        Ok(result)
423    }
424
425    pub async fn send_request_with_auth<T, R>(
426        &self,
427        endpoint: &str,
428        request_body: &T,
429        auth_header: &str,
430    ) -> Result<R, ApiError>
431    where
432        T: Serialize,
433        R: for<'de> Deserialize<'de>,
434    {
435        let base_url = self.config.get_base_url();
436        let url = format!("https://{base_url}/{endpoint}");
437
438        let response = self
439            .client
440            .post(&url)
441            .header("Authorization", auth_header)
442            .json(request_body)
443            .send()
444            .await
445            .map_err(ApiError::HttpClient)?;
446
447        if !response.status().is_success() {
448            let status = response.status();
449            let error_text = response.text().await.unwrap_or_default();
450            return Err(ApiError::InvalidResponse(format!(
451                "HTTP error: {status} - {error_text}"
452            )));
453        }
454
455        let result = response
456            .json::<R>()
457            .await
458            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
459
460        Ok(result)
461    }
462}
463
464/// HTTP/3 client implementation (only available with the "http3" feature)
465#[cfg(feature = "http3")]
466pub struct Http3Client {
467    /// Base configuration
468    config: BaseConfig,
469    /// QUIC endpoint for HTTP/3 connections
470    client: reqwest::Client,
471}
472
473#[cfg(feature = "http3")]
474impl Http3Client {
475    /// Create a new HTTP/3 client with the given configuration
476    pub fn new(config: BaseConfig) -> Result<Self, ApiError> {
477        // Parse the CA certificate chain (may contain root + intermediates + leaf)
478        let certs =
479            reqwest::Certificate::from_pem_bundle(config.server_ca.as_bytes()).map_err(|e| {
480                ApiError::SslConfig(format!("Failed to parse CA certificate chain: {e}"))
481            })?;
482
483        // Build the client with or without mTLS depending on configuration
484        let mut client_builder = reqwest::ClientBuilder::new()
485            .use_rustls_tls()
486            .http3_prior_knowledge();
487
488        // Add all certificates from the chain as trusted roots
489        for cert in certs {
490            client_builder = client_builder.add_root_certificate(cert);
491        }
492
493        // Add mTLS identity if both cert and key are provided
494        if let (Some(cert), Some(key)) = (&config.mtls_cert, &config.mtls_key) {
495            let identity_str = format!("{}{}", cert, key);
496            let identity = reqwest::Identity::from_pem(identity_str.as_bytes()).map_err(|e| {
497                ApiError::SslConfig(format!(
498                    "Failed to create identity from certificate and key: {e}"
499                ))
500            })?;
501            client_builder = client_builder.identity(identity);
502        }
503
504        let client = client_builder
505            .build()
506            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
507
508        Ok(Self { config, client })
509    }
510
511    /// Send a request to the Hessra service
512    pub async fn send_request<T, R>(&self, endpoint: &str, request_body: &T) -> Result<R, ApiError>
513    where
514        T: Serialize,
515        R: for<'de> Deserialize<'de>,
516    {
517        let base_url = self.config.get_base_url();
518        let url = format!("https://{base_url}/{endpoint}");
519
520        let response = self
521            .client
522            .post(&url)
523            .json(request_body)
524            .send()
525            .await
526            .map_err(ApiError::HttpClient)?;
527
528        if !response.status().is_success() {
529            let status = response.status();
530            let error_text = response.text().await.unwrap_or_default();
531            return Err(ApiError::InvalidResponse(format!(
532                "HTTP error: {status} - {error_text}"
533            )));
534        }
535
536        let result = response
537            .json::<R>()
538            .await
539            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
540
541        Ok(result)
542    }
543
544    pub async fn send_request_with_auth<T, R>(
545        &self,
546        endpoint: &str,
547        request_body: &T,
548        auth_header: &str,
549    ) -> Result<R, ApiError>
550    where
551        T: Serialize,
552        R: for<'de> Deserialize<'de>,
553    {
554        let base_url = self.config.get_base_url();
555        let url = format!("https://{base_url}/{endpoint}");
556
557        let response = self
558            .client
559            .post(&url)
560            .header("Authorization", auth_header)
561            .json(request_body)
562            .send()
563            .await
564            .map_err(ApiError::HttpClient)?;
565
566        if !response.status().is_success() {
567            let status = response.status();
568            let error_text = response.text().await.unwrap_or_default();
569            return Err(ApiError::InvalidResponse(format!(
570                "HTTP error: {status} - {error_text}"
571            )));
572        }
573
574        let result = response
575            .json::<R>()
576            .await
577            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
578
579        Ok(result)
580    }
581}
582
583/// The main Hessra client type providing token request and verification
584pub enum HessraClient {
585    /// HTTP/1.1 client
586    Http1(Http1Client),
587    /// HTTP/3 client (only available with the "http3" feature)
588    #[cfg(feature = "http3")]
589    Http3(Http3Client),
590}
591
592/// Builder for creating Hessra clients
593pub struct HessraClientBuilder {
594    /// Base configuration being built
595    config: BaseConfig,
596    /// Protocol to use for the client
597    protocol: hessra_config::Protocol,
598}
599
600impl HessraClientBuilder {
601    /// Create a new client builder with default values
602    pub fn new() -> Self {
603        Self {
604            config: BaseConfig {
605                base_url: String::new(),
606                port: None,
607                mtls_key: None,
608                mtls_cert: None,
609                server_ca: String::new(),
610                public_key: None,
611                personal_keypair: None,
612            },
613            protocol: Protocol::Http1,
614        }
615    }
616
617    /// Create a client builder from a HessraConfig
618    pub fn from_config(mut self, config: &HessraConfig) -> Self {
619        self.config.base_url = config.base_url.clone();
620        self.config.port = config.port;
621        self.config.mtls_key = config.mtls_key.clone();
622        self.config.mtls_cert = config.mtls_cert.clone();
623        self.config.server_ca = config.server_ca.clone();
624        self.config.public_key = config.public_key.clone();
625        self.config.personal_keypair = config.personal_keypair.clone();
626        self.protocol = config.protocol.clone();
627        self
628    }
629
630    /// Set the base URL for the client, e.g. "test.hessra.net"
631    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
632        self.config.base_url = base_url.into();
633        self
634    }
635
636    /// Set the mTLS private key for the client
637    /// PEM formatted string
638    pub fn mtls_key(mut self, mtls_key: impl Into<String>) -> Self {
639        self.config.mtls_key = Some(mtls_key.into());
640        self
641    }
642
643    /// Set the mTLS certificate for the client
644    /// PEM formatted string
645    pub fn mtls_cert(mut self, mtls_cert: impl Into<String>) -> Self {
646        self.config.mtls_cert = Some(mtls_cert.into());
647        self
648    }
649
650    /// Set the server CA certificate for the client
651    /// PEM formatted string
652    pub fn server_ca(mut self, server_ca: impl Into<String>) -> Self {
653        self.config.server_ca = server_ca.into();
654        self
655    }
656
657    /// Set the port for the client
658    pub fn port(mut self, port: u16) -> Self {
659        self.config.port = Some(port);
660        self
661    }
662
663    /// Set the protocol for the client
664    pub fn protocol(mut self, protocol: Protocol) -> Self {
665        self.protocol = protocol;
666        self
667    }
668
669    /// Set the public key for token verification
670    /// PEM formatted string. note, this is JUST the public key, not the entire keypair.
671    pub fn public_key(mut self, public_key: impl Into<String>) -> Self {
672        self.config.public_key = Some(public_key.into());
673        self
674    }
675
676    /// Set the personal keypair for service chain attestation
677    /// PEM formatted string. note, this is the entire keypair
678    /// and needs to be kept secret.
679    pub fn personal_keypair(mut self, keypair: impl Into<String>) -> Self {
680        self.config.personal_keypair = Some(keypair.into());
681        self
682    }
683
684    /// Build the HTTP/1.1 client
685    fn build_http1(&self) -> Result<Http1Client, ApiError> {
686        Http1Client::new(self.config.clone())
687    }
688
689    /// Build the HTTP/3 client
690    #[cfg(feature = "http3")]
691    fn build_http3(&self) -> Result<Http3Client, ApiError> {
692        Http3Client::new(self.config.clone())
693    }
694
695    /// Build the client
696    pub fn build(self) -> Result<HessraClient, ApiError> {
697        match self.protocol {
698            Protocol::Http1 => Ok(HessraClient::Http1(self.build_http1()?)),
699            #[cfg(feature = "http3")]
700            Protocol::Http3 => Ok(HessraClient::Http3(self.build_http3()?)),
701            #[allow(unreachable_patterns)]
702            _ => Err(ApiError::Internal("Unsupported protocol".to_string())),
703        }
704    }
705}
706
707impl Default for HessraClientBuilder {
708    fn default() -> Self {
709        Self::new()
710    }
711}
712
713impl HessraClient {
714    /// Create a new client builder
715    pub fn builder() -> HessraClientBuilder {
716        HessraClientBuilder::new()
717    }
718
719    /// Fetch the public key from the Hessra service without creating a client
720    /// The public_key endpoint is available as both an authenticated and unauthenticated
721    /// request.
722    pub async fn fetch_public_key(
723        base_url: impl Into<String>,
724        port: Option<u16>,
725        server_ca: impl Into<String>,
726    ) -> Result<String, ApiError> {
727        let base_url_str = base_url.into();
728        let server_ca = server_ca.into();
729
730        // Parse the base_url to handle addresses with embedded ports
731        let (host, embedded_port) = parse_server_address(&base_url_str);
732        // Use embedded port if present, otherwise use the provided port parameter
733        let resolved_port = embedded_port.or(port);
734
735        // Create a regular reqwest client (no mTLS)
736        let cert_pem = server_ca.as_bytes();
737        let certs = reqwest::Certificate::from_pem_bundle(cert_pem)
738            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
739
740        let mut client_builder = reqwest::ClientBuilder::new().use_rustls_tls();
741        for cert in certs {
742            client_builder = client_builder.add_root_certificate(cert);
743        }
744
745        let client = client_builder
746            .build()
747            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
748
749        // Format the URL using the parsed host and resolved port
750        let url = match resolved_port {
751            Some(port) => format!("https://{host}:{port}/public_key"),
752            None => format!("https://{host}/public_key"),
753        };
754
755        // Make the request
756        let response = client
757            .get(&url)
758            .send()
759            .await
760            .map_err(ApiError::HttpClient)?;
761
762        if !response.status().is_success() {
763            let status = response.status();
764            let error_text = response.text().await.unwrap_or_default();
765            return Err(ApiError::InvalidResponse(format!(
766                "HTTP error: {status} - {error_text}"
767            )));
768        }
769
770        // Parse the response
771        let result = response
772            .json::<PublicKeyResponse>()
773            .await
774            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
775
776        Ok(result.public_key)
777    }
778
779    /// Fetch the CA certificate from the Hessra service without authentication
780    ///
781    /// This function makes an unauthenticated request to the `/ca_cert` endpoint
782    /// to retrieve the server's CA certificate in PEM format. This is useful for
783    /// bootstrapping trust when setting up a new client.
784    ///
785    /// # Bootstrap Trust Considerations
786    ///
787    /// This function uses the system CA store for the initial connection. If the
788    /// server uses a self-signed certificate, consider using `fetch_ca_cert_insecure`
789    /// instead (with appropriate warnings to users).
790    pub async fn fetch_ca_cert(
791        base_url: impl Into<String>,
792        port: Option<u16>,
793    ) -> Result<String, ApiError> {
794        let base_url_str = base_url.into();
795
796        // Parse the base_url to handle addresses with embedded ports
797        let (host, embedded_port) = parse_server_address(&base_url_str);
798        // Use embedded port if present, otherwise use the provided port parameter
799        let resolved_port = embedded_port.or(port);
800
801        // Create a reqwest client using system CA store
802        let client = reqwest::ClientBuilder::new()
803            .use_rustls_tls()
804            .build()
805            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
806
807        // Format the URL using the parsed host and resolved port
808        let url = match resolved_port {
809            Some(port) => format!("https://{host}:{port}/ca_cert"),
810            None => format!("https://{host}/ca_cert"),
811        };
812
813        // Make the request
814        let response = client
815            .get(&url)
816            .send()
817            .await
818            .map_err(ApiError::HttpClient)?;
819
820        if !response.status().is_success() {
821            let status = response.status();
822            let error_text = response.text().await.unwrap_or_default();
823            return Err(ApiError::InvalidResponse(format!(
824                "HTTP error: {status} - {error_text}"
825            )));
826        }
827
828        // Parse the response
829        let result = response
830            .json::<CaCertResponse>()
831            .await
832            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
833
834        // Validate it's a non-empty PEM certificate
835        if result.ca_cert_pem.is_empty() {
836            return Err(ApiError::InvalidResponse(
837                "Server returned empty CA certificate".to_string(),
838            ));
839        }
840
841        if !result.ca_cert_pem.contains("-----BEGIN CERTIFICATE-----") {
842            return Err(ApiError::InvalidResponse(
843                "Server returned invalid PEM format".to_string(),
844            ));
845        }
846
847        Ok(result.ca_cert_pem)
848    }
849
850    #[cfg(feature = "http3")]
851    pub async fn fetch_public_key_http3(
852        base_url: impl Into<String>,
853        port: Option<u16>,
854        server_ca: impl Into<String>,
855    ) -> Result<String, ApiError> {
856        let base_url_str = base_url.into();
857        let server_ca = server_ca.into();
858
859        // Parse the base_url to handle addresses with embedded ports
860        let (host, embedded_port) = parse_server_address(&base_url_str);
861        // Use embedded port if present, otherwise use the provided port parameter
862        let resolved_port = embedded_port.or(port);
863
864        // Create a regular reqwest client (no mTLS)
865        let cert_pem = server_ca.as_bytes();
866        let certs = reqwest::Certificate::from_pem_bundle(cert_pem)
867            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
868
869        let mut client_builder = reqwest::ClientBuilder::new()
870            .use_rustls_tls()
871            .http3_prior_knowledge();
872        for cert in certs {
873            client_builder = client_builder.add_root_certificate(cert);
874        }
875
876        let client = client_builder
877            .build()
878            .map_err(|e| ApiError::SslConfig(e.to_string()))?;
879
880        // Format the URL using the parsed host and resolved port
881        let url = match resolved_port {
882            Some(port) => format!("https://{host}:{port}/public_key"),
883            None => format!("https://{host}/public_key"),
884        };
885
886        // Make the request
887        let response = client
888            .get(&url)
889            .send()
890            .await
891            .map_err(ApiError::HttpClient)?;
892
893        if !response.status().is_success() {
894            let status = response.status();
895            let error_text = response.text().await.unwrap_or_default();
896            return Err(ApiError::InvalidResponse(format!(
897                "HTTP error: {status} - {error_text}"
898            )));
899        }
900
901        // Parse the response
902        let result = response
903            .json::<PublicKeyResponse>()
904            .await
905            .map_err(|e| ApiError::InvalidResponse(format!("Failed to parse response: {e}")))?;
906
907        Ok(result.public_key)
908    }
909
910    /// Request a token for a resource
911    /// Returns the full TokenResponse which may include pending signoffs for multi-party tokens
912    ///
913    /// # Arguments
914    /// * `resource` - The resource identifier to request authorization for
915    /// * `operation` - The operation to request authorization for
916    /// * `domain` - Optional domain for domain-restricted identity token verification
917    pub async fn request_token(
918        &self,
919        resource: String,
920        operation: String,
921        domain: Option<String>,
922    ) -> Result<TokenResponse, ApiError> {
923        let request = TokenRequest {
924            resource,
925            operation,
926            domain,
927        };
928
929        let response = match self {
930            HessraClient::Http1(client) => {
931                client
932                    .send_request::<_, TokenResponse>("request_token", &request)
933                    .await?
934            }
935            #[cfg(feature = "http3")]
936            HessraClient::Http3(client) => {
937                client
938                    .send_request::<_, TokenResponse>("request_token", &request)
939                    .await?
940            }
941        };
942
943        Ok(response)
944    }
945
946    /// Request a token for a resource using an identity token for authentication
947    /// The identity token will be sent in the Authorization header as a Bearer token
948    /// Returns the full TokenResponse which may include pending signoffs for multi-party tokens
949    ///
950    /// # Arguments
951    /// * `resource` - The resource identifier to request authorization for
952    /// * `operation` - The operation to request authorization for
953    /// * `identity_token` - The identity token to use for authentication
954    /// * `domain` - Optional domain for domain-restricted identity token verification
955    pub async fn request_token_with_identity(
956        &self,
957        resource: String,
958        operation: String,
959        identity_token: String,
960        domain: Option<String>,
961    ) -> Result<TokenResponse, ApiError> {
962        let request = TokenRequest {
963            resource,
964            operation,
965            domain,
966        };
967
968        let response = match self {
969            HessraClient::Http1(client) => {
970                client
971                    .send_request_with_auth::<_, TokenResponse>(
972                        "request_token",
973                        &request,
974                        &format!("Bearer {identity_token}"),
975                    )
976                    .await?
977            }
978            #[cfg(feature = "http3")]
979            HessraClient::Http3(client) => {
980                client
981                    .send_request_with_auth::<_, TokenResponse>(
982                        "request_token",
983                        &request,
984                        &format!("Bearer {identity_token}"),
985                    )
986                    .await?
987            }
988        };
989
990        Ok(response)
991    }
992
993    /// Request a token for a resource (legacy method)
994    /// This method returns just the token string for backward compatibility
995    pub async fn request_token_simple(
996        &self,
997        resource: String,
998        operation: String,
999    ) -> Result<String, ApiError> {
1000        let response = self.request_token(resource, operation, None).await?;
1001
1002        match response.token {
1003            Some(token) => Ok(token),
1004            None => Err(ApiError::TokenRequest(format!(
1005                "Failed to get token: {}",
1006                response.response_msg
1007            ))),
1008        }
1009    }
1010
1011    /// Verify a token for subject doing operation on resource.
1012    /// This will verify the token using the remote authorization service API.
1013    pub async fn verify_token(
1014        &self,
1015        token: String,
1016        subject: String,
1017        resource: String,
1018        operation: String,
1019    ) -> Result<String, ApiError> {
1020        let request = VerifyTokenRequest {
1021            token,
1022            subject,
1023            resource,
1024            operation,
1025        };
1026
1027        let response = match self {
1028            HessraClient::Http1(client) => {
1029                client
1030                    .send_request::<_, VerifyTokenResponse>("verify_token", &request)
1031                    .await?
1032            }
1033            #[cfg(feature = "http3")]
1034            HessraClient::Http3(client) => {
1035                client
1036                    .send_request::<_, VerifyTokenResponse>("verify_token", &request)
1037                    .await?
1038            }
1039        };
1040
1041        Ok(response.response_msg)
1042    }
1043
1044    /// Verify a service chain token. If no component is provided,
1045    /// the entire service chain will be used to verify the token.
1046    /// If a component name is provided, the service chain up to and
1047    /// excluding the component will be used to verify the token. This
1048    /// is useful for a node in the middle of the service chain
1049    /// verifying a token has been attested by all previous nodes.
1050    pub async fn verify_service_chain_token(
1051        &self,
1052        token: String,
1053        subject: String,
1054        resource: String,
1055        component: Option<String>,
1056    ) -> Result<String, ApiError> {
1057        let request = VerifyServiceChainTokenRequest {
1058            token,
1059            subject,
1060            resource,
1061            component,
1062        };
1063
1064        let response = match self {
1065            HessraClient::Http1(client) => {
1066                client
1067                    .send_request::<_, VerifyTokenResponse>("verify_service_chain_token", &request)
1068                    .await?
1069            }
1070            #[cfg(feature = "http3")]
1071            HessraClient::Http3(client) => {
1072                client
1073                    .send_request::<_, VerifyTokenResponse>("verify_service_chain_token", &request)
1074                    .await?
1075            }
1076        };
1077
1078        Ok(response.response_msg)
1079    }
1080
1081    /// Sign a multi-party token by calling an authorization service's signoff endpoint
1082    pub async fn sign_token(
1083        &self,
1084        token: &str,
1085        resource: &str,
1086        operation: &str,
1087    ) -> Result<SignTokenResponse, ApiError> {
1088        let request = SignTokenRequest {
1089            token: token.to_string(),
1090            resource: resource.to_string(),
1091            operation: operation.to_string(),
1092        };
1093
1094        let response = match self {
1095            HessraClient::Http1(client) => {
1096                client
1097                    .send_request::<_, SignTokenResponse>("sign_token", &request)
1098                    .await?
1099            }
1100            #[cfg(feature = "http3")]
1101            HessraClient::Http3(client) => {
1102                client
1103                    .send_request::<_, SignTokenResponse>("sign_token", &request)
1104                    .await?
1105            }
1106        };
1107
1108        Ok(response)
1109    }
1110
1111    /// Get the public key from the server
1112    pub async fn get_public_key(&self) -> Result<String, ApiError> {
1113        let url_path = "public_key";
1114
1115        let response = match self {
1116            HessraClient::Http1(client) => {
1117                // For this endpoint, we just need a GET request, not a POST with a body
1118                let base_url = client.config.get_base_url();
1119                let full_url = format!("https://{base_url}/{url_path}");
1120
1121                let response = client
1122                    .client
1123                    .get(&full_url)
1124                    .send()
1125                    .await
1126                    .map_err(ApiError::HttpClient)?;
1127
1128                if !response.status().is_success() {
1129                    let status = response.status();
1130                    let error_text = response.text().await.unwrap_or_default();
1131                    return Err(ApiError::InvalidResponse(format!(
1132                        "HTTP error: {status} - {error_text}"
1133                    )));
1134                }
1135
1136                response.json::<PublicKeyResponse>().await.map_err(|e| {
1137                    ApiError::InvalidResponse(format!("Failed to parse response: {e}"))
1138                })?
1139            }
1140            #[cfg(feature = "http3")]
1141            HessraClient::Http3(client) => {
1142                let base_url = client.config.get_base_url();
1143                let full_url = format!("https://{base_url}/{url_path}");
1144
1145                let response = client
1146                    .client
1147                    .get(&full_url)
1148                    .send()
1149                    .await
1150                    .map_err(ApiError::HttpClient)?;
1151
1152                if !response.status().is_success() {
1153                    let status = response.status();
1154                    let error_text = response.text().await.unwrap_or_default();
1155                    return Err(ApiError::InvalidResponse(format!(
1156                        "HTTP error: {status} - {error_text}"
1157                    )));
1158                }
1159
1160                response.json::<PublicKeyResponse>().await.map_err(|e| {
1161                    ApiError::InvalidResponse(format!("Failed to parse response: {e}"))
1162                })?
1163            }
1164        };
1165
1166        Ok(response.public_key)
1167    }
1168
1169    /// Request a new identity token from the authorization service
1170    ///
1171    /// This endpoint requires mTLS authentication as it's the initial issuance of an identity token.
1172    /// The identifier parameter is optional when using mTLS, as the identity can be derived from the client certificate.
1173    ///
1174    /// # Arguments
1175    /// * `identifier` - Optional identifier for the identity. Required for non-mTLS future requests, optional with mTLS.
1176    pub async fn request_identity_token(
1177        &self,
1178        identifier: Option<String>,
1179    ) -> Result<IdentityTokenResponse, ApiError> {
1180        let request = IdentityTokenRequest { identifier };
1181
1182        let response = match self {
1183            HessraClient::Http1(client) => {
1184                client
1185                    .send_request::<_, IdentityTokenResponse>("request_identity_token", &request)
1186                    .await?
1187            }
1188            #[cfg(feature = "http3")]
1189            HessraClient::Http3(client) => {
1190                client
1191                    .send_request::<_, IdentityTokenResponse>("request_identity_token", &request)
1192                    .await?
1193            }
1194        };
1195
1196        Ok(response)
1197    }
1198
1199    /// Refresh an existing identity token
1200    ///
1201    /// This endpoint can use either mTLS or the current identity token for authentication.
1202    /// When using identity token authentication (no mTLS), the identifier parameter is required.
1203    /// The current token will be validated and a new token with updated expiration will be issued.
1204    ///
1205    /// # Arguments
1206    /// * `current_token` - The existing identity token to refresh
1207    /// * `identifier` - Optional identifier. Required when not using mTLS authentication.
1208    pub async fn refresh_identity_token(
1209        &self,
1210        current_token: String,
1211        identifier: Option<String>,
1212    ) -> Result<IdentityTokenResponse, ApiError> {
1213        let request = RefreshIdentityTokenRequest {
1214            current_token,
1215            identifier,
1216        };
1217
1218        let response = match self {
1219            HessraClient::Http1(client) => {
1220                client
1221                    .send_request::<_, IdentityTokenResponse>("refresh_identity_token", &request)
1222                    .await?
1223            }
1224            #[cfg(feature = "http3")]
1225            HessraClient::Http3(client) => {
1226                client
1227                    .send_request::<_, IdentityTokenResponse>("refresh_identity_token", &request)
1228                    .await?
1229            }
1230        };
1231
1232        Ok(response)
1233    }
1234
1235    /// Mint a new domain-restricted identity token
1236    ///
1237    /// This endpoint requires mTLS authentication from a "realm" identity (one without domain restriction).
1238    /// The minted token will be restricted to the minting identity's domain and cannot mint further sub-identities.
1239    /// Permissions are determined by domain roles configured on the server.
1240    ///
1241    /// # Arguments
1242    /// * `subject` - The subject identifier for the new identity (e.g., "uri:urn:test:argo-cli1:user123")
1243    /// * `duration` - Optional duration in seconds. If None, server uses configured default.
1244    pub async fn mint_domain_restricted_identity_token(
1245        &self,
1246        subject: String,
1247        duration: Option<u64>,
1248    ) -> Result<MintIdentityTokenResponse, ApiError> {
1249        let request = MintIdentityTokenRequest { subject, duration };
1250
1251        let response = match self {
1252            HessraClient::Http1(client) => {
1253                client
1254                    .send_request::<_, MintIdentityTokenResponse>("mint_identity_token", &request)
1255                    .await?
1256            }
1257            #[cfg(feature = "http3")]
1258            HessraClient::Http3(client) => {
1259                client
1260                    .send_request::<_, MintIdentityTokenResponse>("mint_identity_token", &request)
1261                    .await?
1262            }
1263        };
1264
1265        Ok(response)
1266    }
1267
1268    /// Request a stub token that requires prefix attestation before use.
1269    ///
1270    /// This endpoint requires mTLS authentication from a "realm" identity.
1271    /// The minted stub token will be for a target identity within the realm's domain
1272    /// and will require a trusted third party (identified by prefix_attenuator_key)
1273    /// to add a prefix restriction before the token can be used.
1274    ///
1275    /// # Arguments
1276    /// * `target_identity` - The identity who will use this token (must be in minter's domain)
1277    /// * `resource` - The resource the stub token grants access to
1278    /// * `operation` - The operation allowed on the resource
1279    /// * `prefix_attenuator_key` - Public key that will attest the prefix (format: "ed25519/..." or "secp256r1/...")
1280    /// * `duration` - Optional token duration in seconds (defaults to minter's configured duration)
1281    pub async fn request_stub_token(
1282        &self,
1283        target_identity: String,
1284        resource: String,
1285        operation: String,
1286        prefix_attenuator_key: String,
1287        duration: Option<u64>,
1288    ) -> Result<StubTokenResponse, ApiError> {
1289        let request = StubTokenRequest {
1290            target_identity,
1291            resource,
1292            operation,
1293            prefix_attenuator_key,
1294            duration,
1295        };
1296
1297        let response = match self {
1298            HessraClient::Http1(client) => {
1299                client
1300                    .send_request::<_, StubTokenResponse>("request_stub", &request)
1301                    .await?
1302            }
1303            #[cfg(feature = "http3")]
1304            HessraClient::Http3(client) => {
1305                client
1306                    .send_request::<_, StubTokenResponse>("request_stub", &request)
1307                    .await?
1308            }
1309        };
1310
1311        Ok(response)
1312    }
1313
1314    /// Request a stub token using an identity token for authentication.
1315    ///
1316    /// This is similar to `request_stub_token` but uses an identity token
1317    /// instead of mTLS for authentication. The identity token will be sent
1318    /// in the Authorization header as a Bearer token.
1319    ///
1320    /// # Arguments
1321    /// * `target_identity` - The identity who will use this token (must be in minter's domain)
1322    /// * `resource` - The resource the stub token grants access to
1323    /// * `operation` - The operation allowed on the resource
1324    /// * `prefix_attenuator_key` - Public key that will attest the prefix (format: "ed25519/..." or "secp256r1/...")
1325    /// * `identity_token` - The identity token to use for authentication
1326    /// * `duration` - Optional token duration in seconds (defaults to minter's configured duration)
1327    pub async fn request_stub_token_with_identity(
1328        &self,
1329        target_identity: String,
1330        resource: String,
1331        operation: String,
1332        prefix_attenuator_key: String,
1333        identity_token: String,
1334        duration: Option<u64>,
1335    ) -> Result<StubTokenResponse, ApiError> {
1336        let request = StubTokenRequest {
1337            target_identity,
1338            resource,
1339            operation,
1340            prefix_attenuator_key,
1341            duration,
1342        };
1343
1344        let response = match self {
1345            HessraClient::Http1(client) => {
1346                client
1347                    .send_request_with_auth::<_, StubTokenResponse>(
1348                        "request_stub",
1349                        &request,
1350                        &format!("Bearer {identity_token}"),
1351                    )
1352                    .await?
1353            }
1354            #[cfg(feature = "http3")]
1355            HessraClient::Http3(client) => {
1356                client
1357                    .send_request_with_auth::<_, StubTokenResponse>(
1358                        "request_stub",
1359                        &request,
1360                        &format!("Bearer {identity_token}"),
1361                    )
1362                    .await?
1363            }
1364        };
1365
1366        Ok(response)
1367    }
1368}
1369
1370#[cfg(test)]
1371mod tests {
1372    use super::*;
1373
1374    // Test BaseConfig get_base_url method
1375    #[test]
1376    fn test_base_config_get_base_url_with_port() {
1377        let config = BaseConfig {
1378            base_url: "test.hessra.net".to_string(),
1379            port: Some(443),
1380            mtls_key: None,
1381            mtls_cert: None,
1382            server_ca: "".to_string(),
1383            public_key: None,
1384            personal_keypair: None,
1385        };
1386
1387        assert_eq!(config.get_base_url(), "test.hessra.net:443");
1388    }
1389
1390    #[test]
1391    fn test_base_config_get_base_url_without_port() {
1392        let config = BaseConfig {
1393            base_url: "test.hessra.net".to_string(),
1394            port: None,
1395            mtls_key: None,
1396            mtls_cert: None,
1397            server_ca: "".to_string(),
1398            public_key: None,
1399            personal_keypair: None,
1400        };
1401
1402        assert_eq!(config.get_base_url(), "test.hessra.net");
1403    }
1404
1405    // Test HessraClientBuilder methods
1406    #[test]
1407    fn test_client_builder_methods() {
1408        let builder = HessraClientBuilder::new()
1409            .base_url("test.hessra.net")
1410            .port(443)
1411            .protocol(Protocol::Http1)
1412            .mtls_cert("CERT")
1413            .mtls_key("KEY")
1414            .server_ca("CA")
1415            .public_key("PUBKEY")
1416            .personal_keypair("KEYPAIR");
1417
1418        assert_eq!(builder.config.base_url, "test.hessra.net");
1419        assert_eq!(builder.config.port, Some(443));
1420        assert_eq!(builder.config.mtls_cert, Some("CERT".to_string()));
1421        assert_eq!(builder.config.mtls_key, Some("KEY".to_string()));
1422        assert_eq!(builder.config.server_ca, "CA");
1423        assert_eq!(builder.config.public_key, Some("PUBKEY".to_string()));
1424        assert_eq!(builder.config.personal_keypair, Some("KEYPAIR".to_string()));
1425    }
1426
1427    // Test parse_server_address function
1428    #[test]
1429    fn test_parse_server_address_ip_with_port() {
1430        let (host, port) = parse_server_address("127.0.0.1:4433");
1431        assert_eq!(host, "127.0.0.1");
1432        assert_eq!(port, Some(4433));
1433    }
1434
1435    #[test]
1436    fn test_parse_server_address_ip_only() {
1437        let (host, port) = parse_server_address("127.0.0.1");
1438        assert_eq!(host, "127.0.0.1");
1439        assert_eq!(port, None);
1440    }
1441
1442    #[test]
1443    fn test_parse_server_address_hostname_with_port() {
1444        let (host, port) = parse_server_address("test.hessra.net:443");
1445        assert_eq!(host, "test.hessra.net");
1446        assert_eq!(port, Some(443));
1447    }
1448
1449    #[test]
1450    fn test_parse_server_address_hostname_only() {
1451        let (host, port) = parse_server_address("test.hessra.net");
1452        assert_eq!(host, "test.hessra.net");
1453        assert_eq!(port, None);
1454    }
1455
1456    #[test]
1457    fn test_parse_server_address_with_https_protocol() {
1458        let (host, port) = parse_server_address("https://example.com:8443");
1459        assert_eq!(host, "example.com");
1460        assert_eq!(port, Some(8443));
1461    }
1462
1463    #[test]
1464    fn test_parse_server_address_with_https_protocol_no_port() {
1465        let (host, port) = parse_server_address("https://example.com");
1466        assert_eq!(host, "example.com");
1467        assert_eq!(port, None);
1468    }
1469
1470    #[test]
1471    fn test_parse_server_address_with_path() {
1472        let (host, port) = parse_server_address("https://example.com:8443/some/path");
1473        assert_eq!(host, "example.com");
1474        assert_eq!(port, Some(8443));
1475    }
1476
1477    #[test]
1478    fn test_parse_server_address_ipv6_with_brackets_and_port() {
1479        let (host, port) = parse_server_address("[::1]:8443");
1480        assert_eq!(host, "::1");
1481        assert_eq!(port, Some(8443));
1482    }
1483
1484    #[test]
1485    fn test_parse_server_address_ipv6_with_brackets_no_port() {
1486        let (host, port) = parse_server_address("[::1]");
1487        assert_eq!(host, "::1");
1488        assert_eq!(port, None);
1489    }
1490
1491    #[test]
1492    fn test_parse_server_address_ipv6_full_with_port() {
1493        let (host, port) = parse_server_address("[2001:db8::1]:4433");
1494        assert_eq!(host, "2001:db8::1");
1495        assert_eq!(port, Some(4433));
1496    }
1497
1498    #[test]
1499    fn test_parse_server_address_with_whitespace() {
1500        let (host, port) = parse_server_address("  127.0.0.1:4433  ");
1501        assert_eq!(host, "127.0.0.1");
1502        assert_eq!(port, Some(4433));
1503    }
1504
1505    #[test]
1506    fn test_base_config_get_base_url_with_embedded_port() {
1507        // Test that BaseConfig::get_base_url handles embedded ports correctly
1508        let config = BaseConfig {
1509            base_url: "127.0.0.1:4433".to_string(),
1510            port: None, // No explicit port set
1511            mtls_key: None,
1512            mtls_cert: None,
1513            server_ca: "".to_string(),
1514            public_key: None,
1515            personal_keypair: None,
1516        };
1517        // Should extract the embedded port and use it
1518        assert_eq!(config.get_base_url(), "127.0.0.1:4433");
1519    }
1520
1521    #[test]
1522    fn test_base_config_get_base_url_explicit_port_overrides_embedded() {
1523        // Test that explicitly set port takes precedence over embedded port
1524        let config = BaseConfig {
1525            base_url: "127.0.0.1:4433".to_string(),
1526            port: Some(8080), // Explicit port should override
1527            mtls_key: None,
1528            mtls_cert: None,
1529            server_ca: "".to_string(),
1530            public_key: None,
1531            personal_keypair: None,
1532        };
1533        assert_eq!(config.get_base_url(), "127.0.0.1:8080");
1534    }
1535}