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 failure → 401 `LDAP_AUTH_FAILED`
13//!    with a `WWW-Authenticate: Basic` challenge.
14//!
15//! This is **bind-auth**, not search-then-bind: the DN is built directly from
16//! `uid`/`base_dn` and no directory search is performed. See the Deviations in
17//! `website/docs/reference/plugins/ldap-auth.md`.
18
19use 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
33/// Authenticates HTTP Basic credentials against an LDAP server via simple bind.
34pub struct LdapAuthPlugin {
35    /// Base DN the bind DN is built under (e.g. `ou=users,dc=example,dc=org`).
36    base_dn: String,
37    /// LDAP server URI (`ldap://host:389` or `ldaps://host:636`).
38    ldap_uri: String,
39    /// RDN attribute for the bind DN (APISIX `uid`, default `cn`).
40    uid: String,
41    /// When true, negotiate StartTLS on the connection.
42    use_tls: bool,
43    /// When false, TLS certificate verification is disabled.
44    tls_verify: bool,
45    /// Realm advertised in the `WWW-Authenticate` challenge.
46    realm: String,
47    /// Whole-operation deadline for the connect + bind.
48    timeout: Duration,
49}
50
51impl LdapAuthPlugin {
52    /// Builds the plugin from node config.
53    ///
54    /// Accepted keys:
55    /// - `base_dn` (string, **required**): base DN the bind DN is built under.
56    /// - `ldap_uri` (string, **required**): LDAP server URI, e.g.
57    ///   `ldap://ldap.example.org:389`.
58    /// - `uid` (string, default `"cn"`): RDN attribute prefixing the username
59    ///   in the bind DN.
60    /// - `use_tls` (bool, default `false`): negotiate StartTLS after connecting.
61    /// - `tls_verify` (bool, default `false`): verify the server certificate.
62    /// - `realm` (string, default `"ldap"`): realm in the challenge header.
63    /// - `timeout_ms` (u64, default `10000`): connect + bind deadline.
64    ///
65    /// ```yaml
66    /// type: ldap-auth
67    /// config:
68    ///   base_dn: ou=users,dc=example,dc=org
69    ///   ldap_uri: ldap://ldap.example.org:389
70    ///   uid: cn
71    ///   use_tls: false
72    ///   tls_verify: false
73    /// ```
74    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    /// Builds the 401 rejection carrying the `WWW-Authenticate: Basic` challenge
132    /// and routes the context through the node's error port.
133    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    /// Attempts a simple bind with `dn`/`password` against the LDAP server.
160    /// Returns `Ok(true)` on a successful bind, `Ok(false)` on rejected
161    /// credentials, and `Err` on a connection/transport error.
162    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
182/// Parses an `Authorization: Basic ...` header value into `(username, password)`.
183///
184/// Mirrors the APISIX Lua: the scheme match is case-insensitive, the payload is
185/// standard-base64 decoded, split on the first `:`, and **all whitespace is
186/// stripped** from both fields. Returns `None` when the header is not Basic,
187/// the payload is not valid base64/UTF-8, or there is no `:` separator.
188fn 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
200/// Assembles the bind DN as `<uid>=<username>,<base_dn>` (APISIX's `user_dn`).
201fn 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        // Reject empty credentials: an empty password would trigger an
234        // unauthenticated (anonymous) bind that most servers accept, silently
235        // authenticating anyone. Guard against it explicitly.
236        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        // 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 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        // Never reaches the network: empty password is rejected before binding.
402        assert!(plugin.execute(ctx, &HashMap::new()).await.is_err());
403    }
404}