Skip to main content

alopex_cli/client/
auth.rs

1use std::net::IpAddr;
2use std::path::PathBuf;
3use std::process::Command;
4
5use reqwest::header::{HeaderValue, AUTHORIZATION};
6use reqwest::{ClientBuilder, RequestBuilder};
7use time::OffsetDateTime;
8use url::Url;
9use x509_parser::extensions::GeneralName;
10use x509_parser::pem::parse_x509_pem;
11use x509_parser::prelude::X509Certificate;
12
13use crate::profile::config::{AuthType, ServerConfig};
14
15#[derive(thiserror::Error, Debug)]
16pub enum AuthError {
17    #[error("missing authentication configuration: {0}")]
18    MissingConfig(String),
19    #[error("invalid authentication configuration: {0}")]
20    InvalidConfig(String),
21    #[error("failed to execute password command: {0}")]
22    PasswordCommand(String),
23    #[error("failed to load certificate: {0}")]
24    Certificate(String),
25}
26
27#[derive(Debug, Clone)]
28pub enum AuthConfig {
29    None,
30    Token {
31        token: String,
32    },
33    Basic {
34        username: String,
35        password_command: String,
36    },
37    MTls {
38        cert_path: PathBuf,
39        key_path: PathBuf,
40        server_host: String,
41    },
42}
43
44impl AuthConfig {
45    pub fn from_server_config(config: &ServerConfig) -> Result<Self, AuthError> {
46        let auth_type = config.auth.unwrap_or(AuthType::None);
47        match auth_type {
48            AuthType::None => Ok(Self::None),
49            AuthType::Token => {
50                let token = config
51                    .token
52                    .clone()
53                    .ok_or_else(|| AuthError::MissingConfig("token is required".to_string()))?;
54                Ok(Self::Token { token })
55            }
56            AuthType::Basic => {
57                let username = config
58                    .username
59                    .clone()
60                    .ok_or_else(|| AuthError::MissingConfig("username is required".to_string()))?;
61                let password_command = config.password_command.clone().ok_or_else(|| {
62                    AuthError::MissingConfig("password_command is required".to_string())
63                })?;
64                Ok(Self::Basic {
65                    username,
66                    password_command,
67                })
68            }
69            AuthType::MTls => {
70                let cert_path = config
71                    .cert_path
72                    .clone()
73                    .ok_or_else(|| AuthError::MissingConfig("cert_path is required".to_string()))?;
74                let key_path = config
75                    .key_path
76                    .clone()
77                    .ok_or_else(|| AuthError::MissingConfig("key_path is required".to_string()))?;
78                let server_host = server_host_from_url(&config.url)?;
79                Ok(Self::MTls {
80                    cert_path,
81                    key_path,
82                    server_host,
83                })
84            }
85        }
86    }
87
88    pub fn apply_to_builder(&self, builder: ClientBuilder) -> Result<ClientBuilder, AuthError> {
89        match self {
90            AuthConfig::MTls {
91                cert_path,
92                key_path,
93                server_host,
94            } => {
95                let cert = std::fs::read(cert_path)
96                    .map_err(|err| AuthError::Certificate(format!("cert read failed: {err}")))?;
97                validate_mtls_certificate(&cert, server_host)?;
98                let key = std::fs::read(key_path)
99                    .map_err(|err| AuthError::Certificate(format!("key read failed: {err}")))?;
100                let identity = load_identity_from_parts(&cert, &key)?;
101                Ok(builder.identity(identity))
102            }
103            _ => Ok(builder),
104        }
105    }
106
107    pub fn apply_to_request(&self, request: RequestBuilder) -> Result<RequestBuilder, AuthError> {
108        match self {
109            AuthConfig::None => Ok(request),
110            AuthConfig::Token { token } => {
111                let value = HeaderValue::from_str(&format!("Bearer {}", token))
112                    .map_err(|err| AuthError::InvalidConfig(err.to_string()))?;
113                Ok(request.header(AUTHORIZATION, value))
114            }
115            AuthConfig::Basic {
116                username,
117                password_command,
118            } => {
119                let password = execute_password_command(password_command)?;
120                Ok(request.basic_auth(username, Some(password)))
121            }
122            AuthConfig::MTls { .. } => Ok(request),
123        }
124    }
125}
126
127fn execute_password_command(command: &str) -> Result<String, AuthError> {
128    let mut parts = command.split_whitespace();
129    let Some(program) = parts.next() else {
130        return Err(AuthError::InvalidConfig(
131            "password_command cannot be empty".to_string(),
132        ));
133    };
134    let output = Command::new(program)
135        .args(parts)
136        .output()
137        .map_err(|err| AuthError::PasswordCommand(err.to_string()))?;
138    if !output.status.success() {
139        return Err(AuthError::PasswordCommand(format!(
140            "command exited with status {}",
141            output.status
142        )));
143    }
144    let password = String::from_utf8_lossy(&output.stdout).trim().to_string();
145    if password.is_empty() {
146        return Err(AuthError::PasswordCommand(
147            "password command returned empty output".to_string(),
148        ));
149    }
150    Ok(password)
151}
152
153fn load_identity_from_parts(cert: &[u8], key: &[u8]) -> Result<reqwest::Identity, AuthError> {
154    let mut combined = Vec::with_capacity(cert.len() + key.len() + 1);
155    combined.extend_from_slice(cert);
156    if !combined.ends_with(b"\n") {
157        combined.push(b'\n');
158    }
159    combined.extend_from_slice(key);
160    reqwest::Identity::from_pem(&combined).map_err(|err| AuthError::Certificate(err.to_string()))
161}
162
163fn server_host_from_url(url: &str) -> Result<String, AuthError> {
164    let parsed = Url::parse(url)
165        .map_err(|err| AuthError::InvalidConfig(format!("invalid server url: {err}")))?;
166    parsed
167        .host_str()
168        .map(|host| host.to_string())
169        .ok_or_else(|| AuthError::InvalidConfig("server url missing host".to_string()))
170}
171
172fn validate_mtls_certificate(cert_pem: &[u8], server_host: &str) -> Result<(), AuthError> {
173    let (_, pem) = parse_x509_pem(cert_pem)
174        .map_err(|err| AuthError::Certificate(format!("cert parse failed: {err}")))?;
175    let cert = pem
176        .parse_x509()
177        .map_err(|err| AuthError::Certificate(format!("cert parse failed: {err}")))?;
178    validate_certificate_dates(&cert)?;
179    validate_certificate_host(&cert, server_host)?;
180    Ok(())
181}
182
183fn validate_certificate_dates(cert: &X509Certificate<'_>) -> Result<(), AuthError> {
184    let now = OffsetDateTime::now_utc();
185    let not_before = cert.validity().not_before.to_datetime();
186    let not_after = cert.validity().not_after.to_datetime();
187    if now < not_before {
188        return Err(AuthError::Certificate(
189            "certificate is not valid yet".to_string(),
190        ));
191    }
192    if now > not_after {
193        return Err(AuthError::Certificate(
194            "certificate has expired".to_string(),
195        ));
196    }
197    Ok(())
198}
199
200fn validate_certificate_host(
201    cert: &X509Certificate<'_>,
202    server_host: &str,
203) -> Result<(), AuthError> {
204    let host = normalize_host(server_host);
205    let host_ip = host.parse::<IpAddr>().ok();
206    let mut matched = false;
207
208    if let Ok(Some(san)) = cert.subject_alternative_name() {
209        for name in san.value.general_names.iter() {
210            match name {
211                GeneralName::DNSName(dns) => {
212                    if host_matches_dns(dns, &host) {
213                        matched = true;
214                        break;
215                    }
216                }
217                GeneralName::IPAddress(bytes) => {
218                    if let Some(ip) = host_ip {
219                        if ip_matches_bytes(ip, bytes) {
220                            matched = true;
221                            break;
222                        }
223                    }
224                }
225                _ => {}
226            }
227        }
228    }
229
230    if !matched {
231        if let Some(cn) = cert
232            .subject()
233            .iter_common_name()
234            .next()
235            .and_then(|cn| cn.as_str().ok())
236        {
237            matched = host_matches_dns(cn, &host);
238        }
239    }
240
241    if matched {
242        Ok(())
243    } else {
244        Err(AuthError::Certificate(
245            "certificate does not match server host".to_string(),
246        ))
247    }
248}
249
250fn normalize_host(host: &str) -> String {
251    host.trim_end_matches('.').to_lowercase()
252}
253
254fn host_matches_dns(pattern: &str, host: &str) -> bool {
255    let pattern = normalize_host(pattern);
256    if let Some(suffix) = pattern.strip_prefix("*.") {
257        if host == suffix {
258            return false;
259        }
260        return host.ends_with(&format!(".{suffix}"));
261    }
262    pattern == host
263}
264
265fn ip_matches_bytes(ip: IpAddr, bytes: &[u8]) -> bool {
266    match ip {
267        IpAddr::V4(addr) => bytes == addr.octets(),
268        IpAddr::V6(addr) => bytes == addr.octets(),
269    }
270}