1use async_trait::async_trait;
22use base64::engine::general_purpose::STANDARD;
23use base64::Engine;
24use bytes::Bytes;
25use std::collections::HashMap;
26use std::sync::Arc;
27use std::time::Duration;
28
29use ldap3::{LdapConnAsync, LdapConnSettings};
30
31use crate::context::Context;
32use crate::plugins::resources::PluginResources;
33use crate::plugins::{Plugin, PluginOutput, PluginResult};
34use crate::vars::template::Template;
35
36pub struct LdapAuthPlugin {
38 base_dn: String,
40 ldap_uri: String,
42 uid: String,
44 use_tls: bool,
46 tls_verify: bool,
48 realm: Template,
52 timeout: Duration,
54}
55
56impl LdapAuthPlugin {
57 pub fn from_config(
81 config: &HashMap<String, serde_json::Value>,
82 _resources: &Arc<PluginResources>,
83 ) -> Result<Self, String> {
84 let base_dn = config
85 .get("base_dn")
86 .and_then(|v| v.as_str())
87 .filter(|s| !s.trim().is_empty())
88 .ok_or("ldap-auth plugin requires a non-empty 'base_dn'")?
89 .to_string();
90
91 let ldap_uri = config
92 .get("ldap_uri")
93 .and_then(|v| v.as_str())
94 .filter(|s| !s.trim().is_empty())
95 .ok_or("ldap-auth plugin requires a non-empty 'ldap_uri'")?
96 .to_string();
97
98 let uid = config
99 .get("uid")
100 .and_then(|v| v.as_str())
101 .unwrap_or("cn")
102 .to_string();
103
104 let use_tls = config
105 .get("use_tls")
106 .and_then(|v| v.as_bool())
107 .unwrap_or(false);
108 let tls_verify = config
109 .get("tls_verify")
110 .and_then(|v| v.as_bool())
111 .unwrap_or(false);
112
113 let realm = config
114 .get("realm")
115 .and_then(|v| v.as_str())
116 .unwrap_or("ldap")
117 .to_string();
118 let realm = Template::parse(&realm).0;
121
122 let timeout = Duration::from_millis(
123 config
124 .get("timeout_ms")
125 .and_then(|v| v.as_u64())
126 .unwrap_or(10_000),
127 );
128
129 Ok(Self {
130 base_dn,
131 ldap_uri,
132 uid,
133 use_tls,
134 tls_verify,
135 realm,
136 timeout,
137 })
138 }
139
140 fn reject(&self, ctx: Context, message: &str) -> PluginResult {
145 let mut ctx = ctx;
146 let realm = self.realm.render(&ctx).into_owned();
147 ctx.response.status_code = 401;
148 ctx.response.body = Bytes::from(format!(
149 r#"{{"error": "unauthorized", "message": "{}"}}"#,
150 message
151 ));
152 ctx.response.headers.insert(
153 "content-type".to_string(),
154 vec!["application/json".to_string()],
155 );
156 ctx.response.headers.insert(
157 "www-authenticate".to_string(),
158 vec![format!("Basic realm=\"{}\"", realm)],
159 );
160 Ok(PluginOutput::on_port(ctx, "denied"))
161 }
162
163 fn infra_error(&self, ctx: Context, message: String) -> PluginResult {
171 Err(crate::plugins::util::provider_error::provider_error(
172 ctx,
173 "LDAP_AUTH_PROVIDER_ERROR",
174 message,
175 ))
176 }
177
178 async fn bind(&self, dn: &str, password: &str) -> Result<bool, String> {
182 let settings = LdapConnSettings::new()
183 .set_no_tls_verify(!self.tls_verify)
184 .set_starttls(self.use_tls);
185
186 let (conn, mut ldap) = LdapConnAsync::with_settings(settings, &self.ldap_uri)
187 .await
188 .map_err(|e| e.to_string())?;
189 ldap3::drive!(conn);
190
191 let result = ldap
192 .simple_bind(dn, password)
193 .await
194 .map_err(|e| e.to_string())?;
195 let ok = result.success().is_ok();
196 let _ = ldap.unbind().await;
197 Ok(ok)
198 }
199}
200
201fn parse_basic_credentials(header: &str) -> Option<(String, String)> {
208 let rest = header
209 .strip_prefix("Basic ")
210 .or_else(|| header.strip_prefix("basic "))
211 .or_else(|| header.strip_prefix("BASIC "))?;
212 let decoded = STANDARD.decode(rest.trim()).ok()?;
213 let decoded = String::from_utf8(decoded).ok()?;
214 let (user, pass) = decoded.split_once(':')?;
215 let strip_ws = |s: &str| s.chars().filter(|c| !c.is_whitespace()).collect::<String>();
216 Some((strip_ws(user), strip_ws(pass)))
217}
218
219fn build_bind_dn(uid: &str, username: &str, base_dn: &str) -> String {
221 format!("{}={},{}", uid, username, base_dn)
222}
223
224#[async_trait]
225impl Plugin for LdapAuthPlugin {
226 fn plugin_type(&self) -> &str {
227 "ldap-auth"
228 }
229
230 async fn execute(&self, mut ctx: Context) -> PluginResult {
231 let auth_header = ctx
232 .request
233 .headers
234 .get("authorization")
235 .and_then(|v| v.first())
236 .cloned();
237
238 let header = match auth_header {
239 Some(h) => h,
240 None => return self.reject(ctx, "Missing authorization in request"),
241 };
242
243 let (username, password) = match parse_basic_credentials(&header) {
244 Some(creds) => creds,
245 None => return self.reject(ctx, "Invalid authorization in request"),
246 };
247
248 if username.is_empty() || password.is_empty() {
252 return self.reject(ctx, "Invalid authorization in request");
253 }
254
255 let dn = build_bind_dn(&self.uid, &username, &self.base_dn);
256
257 let bind = tokio::time::timeout(self.timeout, self.bind(&dn, &password)).await;
258 match bind {
259 Ok(Ok(true)) => {
260 ctx.message.insert(
261 "user".to_string(),
262 serde_json::Value::String(username.clone()),
263 );
264 Ok(PluginOutput::success(ctx))
265 }
266 Ok(Ok(false)) => self.reject(ctx, "Invalid user authorization"),
267 Ok(Err(e)) => self.infra_error(ctx, format!("LDAP connection error: {}", e)),
270 Err(_) => self.infra_error(ctx, "LDAP authentication timed out".to_string()),
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 out = plugin.execute(ctx).await.unwrap();
367 assert_eq!(out.port, Some("denied"));
368 assert_eq!(out.context.response.status_code, 401);
369 assert_eq!(
370 out.context.response.headers.get("www-authenticate"),
371 Some(&vec!["Basic realm=\"ldap\"".to_string()])
372 );
373 }
374
375 #[tokio::test]
376 async fn test_reject_realm_renders_template() {
377 let mut cfg = HashMap::new();
379 cfg.insert(
380 "base_dn".to_string(),
381 serde_json::json!("dc=example,dc=org"),
382 );
383 cfg.insert(
384 "ldap_uri".to_string(),
385 serde_json::json!("ldap://localhost:389"),
386 );
387 cfg.insert(
388 "realm".to_string(),
389 serde_json::json!("realm-{{request.host}}"),
390 );
391 let plugin = LdapAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
392
393 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
394 method: "GET".into(),
395 path: "/".into(),
396 host: "tenant-c.example.com".into(),
397 scheme: "http".into(),
398 headers: HashMap::new(),
399 query_params: HashMap::new(),
400 body: Bytes::new(),
401 remote_addr: "1.2.3.4:5".into(),
402 protocol: crate::context::Protocol::Http1,
403 });
404 let out = plugin.execute(ctx).await.unwrap();
405 assert_eq!(out.port, Some("denied"));
406 assert_eq!(
407 out.context.response.headers.get("www-authenticate"),
408 Some(&vec![
409 "Basic realm=\"realm-tenant-c.example.com\"".to_string()
410 ])
411 );
412 }
413
414 #[tokio::test]
415 async fn test_empty_password_rejected() {
416 let mut cfg = HashMap::new();
417 cfg.insert(
418 "base_dn".to_string(),
419 serde_json::json!("dc=example,dc=org"),
420 );
421 cfg.insert(
422 "ldap_uri".to_string(),
423 serde_json::json!("ldap://localhost:389"),
424 );
425 let plugin = LdapAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
426
427 let mut headers = HashMap::new();
428 headers.insert("authorization".to_string(), vec![basic("alice", "")]);
429 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
430 method: "GET".into(),
431 path: "/".into(),
432 host: "h".into(),
433 scheme: "http".into(),
434 headers,
435 query_params: HashMap::new(),
436 body: Bytes::new(),
437 remote_addr: "1.2.3.4:5".into(),
438 protocol: crate::context::Protocol::Http1,
439 });
440 let out = plugin.execute(ctx).await.unwrap();
442 assert_eq!(out.port, Some("denied"));
443 }
444
445 #[tokio::test]
446 async fn test_connection_failure_stays_on_error_port() {
447 let mut cfg = HashMap::new();
451 cfg.insert(
452 "base_dn".to_string(),
453 serde_json::json!("dc=example,dc=org"),
454 );
455 cfg.insert(
456 "ldap_uri".to_string(),
457 serde_json::json!("ldap://127.0.0.1:1"),
458 );
459 cfg.insert("timeout_ms".to_string(), serde_json::json!(500));
460 let plugin = LdapAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
461
462 let mut headers = HashMap::new();
463 headers.insert("authorization".to_string(), vec![basic("alice", "secret")]);
464 let ctx = crate::context::Context::new(crate::context::GatewayRequest {
465 method: "GET".into(),
466 path: "/".into(),
467 host: "h".into(),
468 scheme: "http".into(),
469 headers,
470 query_params: HashMap::new(),
471 body: Bytes::new(),
472 remote_addr: "1.2.3.4:5".into(),
473 protocol: crate::context::Protocol::Http1,
474 });
475 let err = plugin.execute(ctx).await.unwrap_err();
476 crate::plugins::util::provider_error::testing::assert_provider_error(
477 &err,
478 "LDAP_AUTH_PROVIDER_ERROR",
479 );
480 }
481}