featherbit/plugins/native/
ldap_auth.rs1use async_trait::async_trait;
20use base64::engine::general_purpose::STANDARD;
21use base64::Engine;
22use bytes::Bytes;
23use std::collections::HashMap;
24use std::sync::Arc;
25use std::time::Duration;
26
27use ldap3::{LdapConnAsync, LdapConnSettings};
28
29use crate::context::{Context, GatewayError};
30use crate::plugins::resources::PluginResources;
31use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
32
33pub struct LdapAuthPlugin {
35 base_dn: String,
37 ldap_uri: String,
39 uid: String,
41 use_tls: bool,
43 tls_verify: bool,
45 realm: String,
47 timeout: Duration,
49}
50
51impl LdapAuthPlugin {
52 pub fn from_config(
75 config: &HashMap<String, serde_json::Value>,
76 _resources: &Arc<PluginResources>,
77 ) -> Result<Self, String> {
78 let base_dn = config
79 .get("base_dn")
80 .and_then(|v| v.as_str())
81 .filter(|s| !s.trim().is_empty())
82 .ok_or("ldap-auth plugin requires a non-empty 'base_dn'")?
83 .to_string();
84
85 let ldap_uri = config
86 .get("ldap_uri")
87 .and_then(|v| v.as_str())
88 .filter(|s| !s.trim().is_empty())
89 .ok_or("ldap-auth plugin requires a non-empty 'ldap_uri'")?
90 .to_string();
91
92 let uid = config
93 .get("uid")
94 .and_then(|v| v.as_str())
95 .unwrap_or("cn")
96 .to_string();
97
98 let use_tls = config
99 .get("use_tls")
100 .and_then(|v| v.as_bool())
101 .unwrap_or(false);
102 let tls_verify = config
103 .get("tls_verify")
104 .and_then(|v| v.as_bool())
105 .unwrap_or(false);
106
107 let realm = config
108 .get("realm")
109 .and_then(|v| v.as_str())
110 .unwrap_or("ldap")
111 .to_string();
112
113 let timeout = Duration::from_millis(
114 config
115 .get("timeout_ms")
116 .and_then(|v| v.as_u64())
117 .unwrap_or(10_000),
118 );
119
120 Ok(Self {
121 base_dn,
122 ldap_uri,
123 uid,
124 use_tls,
125 tls_verify,
126 realm,
127 timeout,
128 })
129 }
130
131 fn reject(&self, ctx: Context, message: &str) -> PluginResult {
134 let mut ctx = ctx;
135 ctx.response.status_code = 401;
136 ctx.response.body = Bytes::from(format!(
137 r#"{{"error": "unauthorized", "message": "{}"}}"#,
138 message
139 ));
140 ctx.response.headers.insert(
141 "content-type".to_string(),
142 vec!["application/json".to_string()],
143 );
144 ctx.response.headers.insert(
145 "www-authenticate".to_string(),
146 vec![format!("Basic realm=\"{}\"", self.realm)],
147 );
148 Err(PluginExecutionError {
149 context: ctx,
150 error: GatewayError {
151 node_id: String::new(),
152 code: "LDAP_AUTH_FAILED".to_string(),
153 message: message.to_string(),
154 metadata: HashMap::new(),
155 },
156 })
157 }
158
159 async fn bind(&self, dn: &str, password: &str) -> Result<bool, String> {
163 let settings = LdapConnSettings::new()
164 .set_no_tls_verify(!self.tls_verify)
165 .set_starttls(self.use_tls);
166
167 let (conn, mut ldap) = LdapConnAsync::with_settings(settings, &self.ldap_uri)
168 .await
169 .map_err(|e| e.to_string())?;
170 ldap3::drive!(conn);
171
172 let result = ldap
173 .simple_bind(dn, password)
174 .await
175 .map_err(|e| e.to_string())?;
176 let ok = result.success().is_ok();
177 let _ = ldap.unbind().await;
178 Ok(ok)
179 }
180}
181
182fn parse_basic_credentials(header: &str) -> Option<(String, String)> {
189 let rest = header
190 .strip_prefix("Basic ")
191 .or_else(|| header.strip_prefix("basic "))
192 .or_else(|| header.strip_prefix("BASIC "))?;
193 let decoded = STANDARD.decode(rest.trim()).ok()?;
194 let decoded = String::from_utf8(decoded).ok()?;
195 let (user, pass) = decoded.split_once(':')?;
196 let strip_ws = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>();
197 Some((strip_ws(user), strip_ws(pass)))
198}
199
200fn build_bind_dn(uid: &str, username: &str, base_dn: &str) -> String {
202 format!("{}={},{}", uid, username, base_dn)
203}
204
205#[async_trait]
206impl Plugin for LdapAuthPlugin {
207 fn plugin_type(&self) -> &str {
208 "ldap-auth"
209 }
210
211 async fn execute(
212 &self,
213 mut ctx: Context,
214 _named_inputs: &HashMap<String, serde_json::Value>,
215 ) -> PluginResult {
216 let auth_header = ctx
217 .request
218 .headers
219 .get("authorization")
220 .and_then(|v| v.first())
221 .cloned();
222
223 let header = match auth_header {
224 Some(h) => h,
225 None => return self.reject(ctx, "Missing authorization in request"),
226 };
227
228 let (username, password) = match parse_basic_credentials(&header) {
229 Some(creds) => creds,
230 None => return self.reject(ctx, "Invalid authorization in request"),
231 };
232
233 if username.is_empty() || password.is_empty() {
237 return self.reject(ctx, "Invalid authorization in request");
238 }
239
240 let dn = build_bind_dn(&self.uid, &username, &self.base_dn);
241
242 let bind = tokio::time::timeout(self.timeout, self.bind(&dn, &password)).await;
243 match bind {
244 Ok(Ok(true)) => {
245 ctx.message.insert(
246 "user".to_string(),
247 serde_json::Value::String(username.clone()),
248 );
249 Ok(PluginOutput {
250 context: ctx,
251 named_outputs: HashMap::new(),
252 })
253 }
254 Ok(Ok(false)) => self.reject(ctx, "Invalid user authorization"),
255 Ok(Err(e)) => {
256 let mut ctx = ctx;
257 ctx.response.status_code = 401;
258 ctx.response.headers.insert(
259 "www-authenticate".to_string(),
260 vec![format!("Basic realm=\"{}\"", self.realm)],
261 );
262 Err(PluginExecutionError {
263 context: ctx,
264 error: GatewayError {
265 node_id: String::new(),
266 code: "LDAP_AUTH_FAILED".to_string(),
267 message: format!("LDAP connection error: {}", e),
268 metadata: HashMap::new(),
269 },
270 })
271 }
272 Err(_) => self.reject(ctx, "LDAP authentication timed out"),
273 }
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn basic(user: &str, pass: &str) -> String {
282 format!("Basic {}", STANDARD.encode(format!("{}:{}", user, pass)))
283 }
284
285 #[test]
286 fn test_from_config_requires_base_dn_and_uri() {
287 let mut cfg = HashMap::new();
288 assert!(LdapAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
289 cfg.insert(
290 "base_dn".to_string(),
291 serde_json::json!("dc=example,dc=org"),
292 );
293 assert!(LdapAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
294 cfg.insert(
295 "ldap_uri".to_string(),
296 serde_json::json!("ldap://localhost:389"),
297 );
298 let plugin = LdapAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
299 assert_eq!(plugin.uid, "cn");
300 assert_eq!(plugin.timeout, Duration::from_millis(10_000));
301 }
302
303 #[test]
304 fn test_parse_basic_credentials() {
305 let (u, p) = parse_basic_credentials(&basic("alice", "s3cret")).unwrap();
306 assert_eq!(u, "alice");
307 assert_eq!(p, "s3cret");
308
309 let header = format!("basic {}", STANDARD.encode("bob:pw"));
311 assert_eq!(
312 parse_basic_credentials(&header),
313 Some(("bob".into(), "pw".into()))
314 );
315
316 let header = format!("Basic {}", STANDARD.encode("a li ce:pa ss"));
318 assert_eq!(
319 parse_basic_credentials(&header),
320 Some(("alice".into(), "pass".into()))
321 );
322 }
323
324 #[test]
325 fn test_parse_basic_credentials_rejects_malformed() {
326 assert!(parse_basic_credentials("Bearer xyz").is_none());
327 assert!(parse_basic_credentials("Basic !!!not-base64!!!").is_none());
328 let header = format!("Basic {}", STANDARD.encode("nocolon"));
330 assert!(parse_basic_credentials(&header).is_none());
331 }
332
333 #[test]
334 fn test_build_bind_dn() {
335 assert_eq!(
336 build_bind_dn("cn", "alice", "ou=users,dc=example,dc=org"),
337 "cn=alice,ou=users,dc=example,dc=org"
338 );
339 assert_eq!(build_bind_dn("uid", "bob", "dc=corp"), "uid=bob,dc=corp");
340 }
341
342 #[tokio::test]
343 async fn test_missing_header_rejected() {
344 let mut cfg = HashMap::new();
345 cfg.insert(
346 "base_dn".to_string(),
347 serde_json::json!("dc=example,dc=org"),
348 );
349 cfg.insert(
350 "ldap_uri".to_string(),
351 serde_json::json!("ldap://localhost:389"),
352 );
353 let plugin = LdapAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
354
355 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
356 method: "GET".into(),
357 path: "/".into(),
358 host: "h".into(),
359 scheme: "http".into(),
360 headers: HashMap::new(),
361 query_params: HashMap::new(),
362 body: Bytes::new(),
363 remote_addr: "1.2.3.4:5".into(),
364 protocol: crate::context::Protocol::Http1,
365 });
366 let err = plugin.execute(ctx, &HashMap::new()).await.unwrap_err();
367 assert_eq!(err.error.code, "LDAP_AUTH_FAILED");
368 assert_eq!(err.context.response.status_code, 401);
369 assert_eq!(
370 err.context.response.headers.get("www-authenticate"),
371 Some(&vec!["Basic realm=\"ldap\"".to_string()])
372 );
373 }
374
375 #[tokio::test]
376 async fn test_empty_password_rejected() {
377 let mut cfg = HashMap::new();
378 cfg.insert(
379 "base_dn".to_string(),
380 serde_json::json!("dc=example,dc=org"),
381 );
382 cfg.insert(
383 "ldap_uri".to_string(),
384 serde_json::json!("ldap://localhost:389"),
385 );
386 let plugin = LdapAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
387
388 let mut headers = HashMap::new();
389 headers.insert("authorization".to_string(), vec![basic("alice", "")]);
390 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
391 method: "GET".into(),
392 path: "/".into(),
393 host: "h".into(),
394 scheme: "http".into(),
395 headers,
396 query_params: HashMap::new(),
397 body: Bytes::new(),
398 remote_addr: "1.2.3.4:5".into(),
399 protocol: crate::context::Protocol::Http1,
400 });
401 assert!(plugin.execute(ctx, &HashMap::new()).await.is_err());
403 }
404}