alopex_cli/client/
auth.rs1use 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}