Skip to main content

featherbit/plugins/native/
ldap_auth.rs

1//! LDAP Basic-auth plugin (`ldap-auth`).
2//!
3//! Authenticates the request's HTTP Basic credentials against an LDAP server by
4//! performing a **simple bind** with the user's DN and the presented password.
5//! Port of APISIX's `ldap-auth` plugin (the bind-authentication core of it).
6//!
7//! Flow (matching `apisix/plugins/ldap-auth.lua`):
8//! 1. Parse the `Authorization: Basic <base64(user:pass)>` header.
9//! 2. Assemble the bind DN as `<uid>=<username>,<base_dn>`.
10//! 3. Connect to `ldap_uri` and attempt a simple bind with that DN + password.
11//! 4. Bind success → continue (`context.message["user"] = username`); a missing
12//!    header, malformed credentials, or a bind rejection → 401, exiting on the
13//!    `denied` port with a `WWW-Authenticate: Basic` challenge. A connection
14//!    error or a connect+bind timeout is a genuine infrastructure failure and
15//!    stays on the `error` port instead.
16//!
17//! This is **bind-auth**, not search-then-bind: the DN is built directly from
18//! `uid`/`base_dn` and no directory search is performed. See the Deviations in
19//! `website/docs/reference/plugins/ldap-auth.md`.
20
21use 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
36/// Authenticates HTTP Basic credentials against an LDAP server via simple bind.
37pub struct LdapAuthPlugin {
38    /// Base DN the bind DN is built under (e.g. `ou=users,dc=example,dc=org`).
39    base_dn: String,
40    /// LDAP server URI (`ldap://host:389` or `ldaps://host:636`).
41    ldap_uri: String,
42    /// RDN attribute for the bind DN (APISIX `uid`, default `cn`).
43    uid: String,
44    /// When true, negotiate StartTLS on the connection.
45    use_tls: bool,
46    /// When false, TLS certificate verification is disabled.
47    tls_verify: bool,
48    /// Realm advertised in the `WWW-Authenticate` challenge. Supports
49    /// `{{namespace.path}}` references (no legacy `$var` interpolation —
50    /// `realm` never supported it, so this sweep must not start).
51    realm: Template,
52    /// Whole-operation deadline for the connect + bind.
53    timeout: Duration,
54}
55
56impl LdapAuthPlugin {
57    /// Builds the plugin from node config.
58    ///
59    /// Accepted keys:
60    /// - `base_dn` (string, **required**): base DN the bind DN is built under.
61    /// - `ldap_uri` (string, **required**): LDAP server URI, e.g.
62    ///   `ldap://ldap.example.org:389`.
63    /// - `uid` (string, default `"cn"`): RDN attribute prefixing the username
64    ///   in the bind DN.
65    /// - `use_tls` (bool, default `false`): negotiate StartTLS after connecting.
66    /// - `tls_verify` (bool, default `false`): verify the server certificate.
67    /// - `realm` (string, default `"ldap"`): realm in the challenge header;
68    ///   supports `{{namespace.path}}` references.
69    /// - `timeout_ms` (u64, default `10000`): connect + bind deadline.
70    ///
71    /// ```yaml
72    /// type: ldap-auth
73    /// config:
74    ///   base_dn: ou=users,dc=example,dc=org
75    ///   ldap_uri: ldap://ldap.example.org:389
76    ///   uid: cn
77    ///   use_tls: false
78    ///   tls_verify: false
79    /// ```
80    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        // Discard warnings here — the compile-time walk (a later task)
119        // reports well-formed-but-unknown references; execution must not.
120        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    /// Builds the 401 rejection carrying the `WWW-Authenticate: Basic`
141    /// challenge and exits on the node's `denied` port. Reserved for
142    /// deliberate credential rejections — a missing/malformed header, empty
143    /// credentials, or a bind the server actively refused.
144    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    /// Builds a genuine infrastructure-failure `Err` (LDAP unreachable, or the
164    /// connect+bind operation timed out) — unlike `reject`, this exits
165    /// through the `error` port because the node could not do its job, not
166    /// because a presented credential was deliberately refused. The prepared
167    /// response is the shared `502 provider_error` shape: no `Basic`
168    /// challenge, so a browser does not re-prompt for a password that was
169    /// never checked.
170    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    /// Attempts a simple bind with `dn`/`password` against the LDAP server.
179    /// Returns `Ok(true)` on a successful bind, `Ok(false)` on rejected
180    /// credentials, and `Err` on a connection/transport error.
181    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
201/// Parses an `Authorization: Basic ...` header value into `(username, password)`.
202///
203/// Mirrors the APISIX Lua: the scheme match is case-insensitive, the payload is
204/// standard-base64 decoded, split on the first `:`, and **all whitespace is
205/// stripped** from both fields. Returns `None` when the header is not Basic,
206/// the payload is not valid base64/UTF-8, or there is no `:` separator.
207fn 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
219/// Assembles the bind DN as `<uid>=<username>,<base_dn>` (APISIX's `user_dn`).
220fn 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        // Reject empty credentials: an empty password would trigger an
249        // unauthenticated (anonymous) bind that most servers accept, silently
250        // authenticating anyone. Guard against it explicitly.
251        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            // Connection/transport error: the node could not reach the LDAP
268            // server at all, a genuine infra failure, not a credential denial.
269            Ok(Err(e)) => self.infra_error(ctx, format!("LDAP connection error: {}", e)),
270            // The connect+bind deadline elapsed: also an infra failure (the
271            // server may be slow/unreachable), not a deliberate rejection.
272            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        // case-insensitive scheme
310        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        // whitespace stripped from both fields (APISIX gsub behavior)
317        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        // no colon separator
329        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        // `realm` must render `{{request.host}}` per request.
378        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        // Never reaches the network: empty password is rejected before binding.
441        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        // A genuine infrastructure failure (server unreachable) must stay a
448        // raw `Err`, unlike the deliberate-denial paths above which now exit
449        // `Ok` on the `denied` port.
450        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}