Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
68621d5
feat(asn1): add pure-Lua BER decode primitives
janiussyafiq Jul 17, 2026
80231df
feat(protocol): add pure-Lua LDAP message decoder
janiussyafiq Jul 17, 2026
86915b0
feat(client): decode responses in pure Lua, drop rasn
janiussyafiq Jul 17, 2026
19293ad
feat(client): add pinned-connection session
janiussyafiq Jul 17, 2026
d05149e
feat(filter): add RFC 4515 assertion-value escaping
janiussyafiq Jul 17, 2026
82bba1a
feat: restore resty.ldap/resty.ldap.ldap for ldap-auth compat
janiussyafiq Jul 17, 2026
850031e
build: remove Rust, ship pure-Lua builtin v0.3.0 rockspec
janiussyafiq Jul 17, 2026
c6a832c
test: add AD-shaped fixture (login attr != RDN) + binary-attr e2e
janiussyafiq Jul 17, 2026
a819f25
fix(asn1): surface decoder errors on malformed BER
janiussyafiq Jul 17, 2026
8ddfff4
chore: drop stale Rust entries from .gitignore
janiussyafiq Jul 17, 2026
1bbb2a8
fix(asn1): reject constructed OCTET STRING and fix length-header trun…
janiussyafiq Jul 17, 2026
1597923
fix(filter): escape < and > in assertion values
janiussyafiq Jul 17, 2026
17c78cd
docs: document session methods and restored compat modules
janiussyafiq Jul 20, 2026
c1613fd
ci: fix workflow for retired runner, moved image, expired certs
janiussyafiq Jul 20, 2026
4586aa0
chore: clean up comments and test numbering
janiussyafiq Jul 20, 2026
08553fa
feat: enhance LDAP client with error handling and connection manageme…
janiussyafiq Jul 21, 2026
abc9191
feat(asn1,protocol): strict BER/LDAP decoding and hardened encoding
janiussyafiq Jul 22, 2026
e63bae9
feat: enhance LDAP authentication with improved username validation a…
janiussyafiq Jul 22, 2026
3a4face
feat: improve username handling in ldap_authenticate by escaping DN m…
janiussyafiq Jul 22, 2026
f90c6f1
test: consolidate duplicate coverage into canonical homes
janiussyafiq Jul 22, 2026
2b6e552
feat: enhance ldap_authenticate to return canonical user DN and impro…
janiussyafiq Jul 22, 2026
c57a8d3
test: tolerate CI environment differences
janiussyafiq Jul 22, 2026
ef1f64d
fix: replace NUL-delimited response reader with plain receive
janiussyafiq Jul 22, 2026
bf897a5
ci: fail fast when OpenLDAP does not become ready
janiussyafiq Jul 22, 2026
7b1436f
fix: never pool bound LDAP sockets; wrap message IDs at maxInt
janiussyafiq Jul 23, 2026
89dd1b0
ci: pin actions/checkout to a commit hash
janiussyafiq Jul 23, 2026
b665ffa
refactor: rebuild resty.ldap on client and drop resty.ldap.ldap
janiussyafiq Jul 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .cargo/config.toml

This file was deleted.

45 changes: 26 additions & 19 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,44 +9,51 @@ on:

jobs:
build:
runs-on: "ubuntu-20.04"
runs-on: "ubuntu-latest"
env:
OPENRESTY_PREFIX: "/usr/local/openresty"

steps:
- uses: actions/checkout@v2
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: get dependencies
run: |
sudo apt-get -y install --no-install-recommends wget curl gnupg ca-certificates lsb-release
sudo apt-get update
sudo apt-get -y install --no-install-recommends wget curl gnupg ca-certificates lsb-release cpanminus

# openresty
wget -O - https://openresty.org/package/pubkey.gpg | sudo apt-key add -
echo "deb http://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list
sudo install -m 0755 -d /etc/apt/keyrings
wget -O - https://openresty.org/package/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/keyrings/openresty.gpg
echo "deb [signed-by=/etc/apt/keyrings/openresty.gpg] http://openresty.org/package/ubuntu $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/openresty.list
sudo apt-get update
sudo apt-get -y install openresty

# Test::Nginx
sudo cpan Test::Nginx

# docker
sudo apt-get remove docker docker-engine docker.io containerd runc
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo docker run hello-world

# rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
sudo cpanm --notest Test::Nginx

# luarocks
sudo apt -y install lua5.1 luarocks
luarocks make rockspec/lua-resty-ldap-local-0.rockspec --local --tree ./deps # install deps to local

- name: script
run: |
sudo docker run --detach --rm --name openldap -p 1389:1389 -p 1636:1636 -v $PWD/t/certs:/opt/bitnami/openldap/certs -e LDAP_ENABLE_TLS=yes -e LDAP_TLS_CERT_FILE=/opt/bitnami/openldap/certs/localhost_slapd_cert.pem -e LDAP_TLS_KEY_FILE=/opt/bitnami/openldap/certs/localhost_slapd_key.pem -e LDAP_TLS_CA_FILE=/opt/bitnami/openldap/certs/mycacert.crt -e LDAP_ADMIN_USERNAME=admin -e LDAP_ADMIN_PASSWORD=adminpassword -e LDAP_USERS=user01,user02 -e LDAP_PASSWORDS=password1,password2 bitnami/openldap:2.6
sleep 3
sudo docker run --detach --rm --name openldap -p 1389:1389 -p 1636:1636 -v $PWD/t/certs:/opt/bitnami/openldap/certs -e LDAP_ENABLE_TLS=yes -e LDAP_TLS_CERT_FILE=/opt/bitnami/openldap/certs/localhost_slapd_cert.pem -e LDAP_TLS_KEY_FILE=/opt/bitnami/openldap/certs/localhost_slapd_key.pem -e LDAP_TLS_CA_FILE=/opt/bitnami/openldap/certs/mycacert.crt -e LDAP_ADMIN_USERNAME=admin -e LDAP_ADMIN_PASSWORD=adminpassword -e LDAP_USERS=user01,user02 -e LDAP_PASSWORDS=password1,password2 bitnamilegacy/openldap:2.6.10-debian-12-r4
ok=0
for _ in $(seq 1 60); do
if docker exec openldap ldapsearch -x -H ldap://127.0.0.1:1389 -D "cn=admin,dc=example,dc=org" -w adminpassword -b "dc=example,dc=org" -s base >/dev/null 2>&1; then
ok=$((ok+1))
if [ "$ok" -ge 3 ]; then break; fi
else
ok=0
fi
sleep 1
done
if [ "$ok" -lt 3 ]; then
echo "OpenLDAP did not become ready" >&2
docker logs openldap || true
exit 1
fi
docker cp t/fixtures/ad.ldif openldap:/tmp/ad.ldif
docker exec openldap ldapadd -x -H ldap://127.0.0.1:1389 -D "cn=admin,dc=example,dc=org" -w adminpassword -f /tmp/ad.ldif
export PATH=$OPENRESTY_PREFIX/nginx/sbin:$OPENRESTY_PREFIX/luajit/bin:$PATH
make test
7 changes: 2 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# dev
t/servroot


# Added by cargo

/target
/Cargo.lock
# luarocks --tree ./deps build output (see .github/workflows/ci.yml)
deps/
17 changes: 0 additions & 17 deletions Cargo.toml

This file was deleted.

49 changes: 7 additions & 42 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,52 +1,17 @@
UNAME ?= $(shell uname)
INSTALL ?= install
ECHO ?= echo
C_SO_NAME := librasn.so

ifeq ($(UNAME),Darwin)
C_SO_NAME := librasn.dylib
endif

all:
@echo --- Build
@echo CFLAGS: $(CFLAGS)
@echo LIBFLAG: $(LIBFLAG)
@echo LUA_LIBDIR: $(LUA_LIBDIR)
@echo LUA_BINDIR: $(LUA_BINDIR)
@echo LUA_INCDIR: $(LUA_INCDIR)

@echo --- Check Rust toolchain
@utils/check-rust.sh

@echo --- Build Rust cdylib
cargo build --release

### install: Install the library to runtime
.PHONY: install
install:
@echo --- Install
@echo INST_PREFIX: $(INST_PREFIX)
@echo INST_BINDIR: $(INST_BINDIR)
@echo INST_LIBDIR: $(INST_LIBDIR)
@echo INST_LUADIR: $(INST_LUADIR)
@echo INST_CONFDIR: $(INST_CONFDIR)
$(INSTALL) -d $(INST_LUADIR)/resty/ldap/
$(INSTALL) lib/resty/ldap/*.lua $(INST_LUADIR)/resty/ldap/
$(INSTALL) target/release/${C_SO_NAME} $(INST_LIBDIR)/rasn.so

### dev: Create a development ENV
### dev: Install runtime dependencies locally
.PHONY: dev
dev:
luarocks install rockspec/lua-resty-ldap-main-0.rockspec --only-deps --local

### test: Run the test suite
.PHONY: test
test:
git apply t/patch/unknown_op.patch
prove -r t/; ret=$$?; git apply t/patch/unknown_op.patch -R; exit $$ret

### help: Show Makefile rules
.PHONY: help
help:
@echo Makefile rules:
@echo
@grep -E '^### [-A-Za-z0-9_]+:' Makefile | sed 's/###/ /'

test:
git apply t/patch/unknown_op.patch
prove -r t/
git apply t/patch/unknown_op.patch -R
56 changes: 55 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ To load this module:

`bind_dn` and `password` can be `nil` values, that means the client is instructed to do anonymous bind.

`res` is a boolean type value that will be true when authentication is successful, when it is false, `err` will contain errors.
`res` is `true` when authentication succeeds and `false` when the server rejects the bind (or the request cannot be encoded); on a transport or decoding failure `res` is `nil`. In both failure cases `err` carries the error.

#### search

Expand All @@ -84,3 +84,57 @@ To load this module:
`filter` is an LDAP filter expression in string. Default is `(objectClass=*)`.

`attributes` is an array table that contains one to more query fields that you need to have the LDAP server return. Default is `["objectClass"]`.

#### connect

**syntax:** *ok, err = client:connect()*

Establishes and pins the underlying connection, so that subsequent operations on this client (for example a `simple_bind` followed by a `search`) run on that same connection instead of each drawing one from the connection pool. Release the connection with `set_keepalive` or `close` when done.

An unrecoverable socket error also releases the pin, so the next operation draws a fresh connection instead of reusing the dead one.

#### set_keepalive

**syntax:** *ok, err = client:set_keepalive()*

Releases a pinned connection back into the connection pool. The client remains usable; subsequent operations draw pooled connections as before.

#### close

**syntax:** *ok, err = client:close()*

Closes a pinned connection without returning it to the pool.

### resty.ldap

Compatibility entrypoint kept for APISIX's `ldap-auth` plugin, preserving the v0.1.0 `ldap_authenticate` contract as a thin wrapper over `resty.ldap.client`. New code should use `resty.ldap.client` instead.

```lua
local ldap = require "resty.ldap"
```

#### ldap_authenticate

**syntax:** *ok, err, user_dn = ldap.ldap_authenticate(username, password, conf)*

Binds against the directory as `<attribute>=<username>,<base_dn>` with the given password. `ok` is `true` when authentication succeeds, `false` when the server rejects the credentials, and `nil` on a connect/TLS/transport failure; in both failure cases `err` carries the error. The connection is always closed after the bind attempt — a socket that carried a Bind is never returned to the pool.

`username` is escaped per RFC 4514 when the bind DN is built, so DN metacharacters (`, + " \ < > ; =`, a leading space or `#`, a trailing space, or a NUL) are treated as literal characters of the RDN value instead of injecting extra RDN components. Any username string is accepted, as in v0.1.0.

`user_dn` is the canonical (escaped) DN the bind was performed with; callers that key identity on the DN (e.g. the APISIX `ldap-auth` consumer lookup) should use it instead of rebuilding the DN from the raw username. It is returned whenever a bind was attempted, and is `nil` on connect/STARTTLS/handshake failures.

`conf` is a table of below items (note the key names differ from `resty.ldap.client`'s `client_config`; they follow the v0.1.0 / `ldap-auth` contract):

| key | type | default value | Description |
| ----------- | ----------- | ----------- | ----------- |
| `ldap_host` | string | localhost | LDAP server host. |
| `ldap_port` | number | 389 | LDAP server port. |
| `timeout` | number | 10000 | Socket timeout in milliseconds. |
| `keepalive` | number | 60000 | Accepted for v0.1.0 compatibility, but unused: sockets that carried a bind are always closed, never pooled. |
| `start_tls` | boolean | false | Issue the StartTLS extended operation before binding. |
| `ldaps` | boolean | false | Connect using LDAP over TLS. |
| `tls_verify` | boolean | false | Verify the server certificate during the TLS handshake. This is the key APISIX's `ldap-auth` passes. |
| `verify_ldap_host` | boolean | false | Legacy alias for `tls_verify` (pre-0.2). Honoured only when `tls_verify` is not set. |
| `base_dn` | string | ou=users,dc=example,dc=org | Base DN the username is appended to. |
| `attribute` | string | cn | RDN attribute the username is bound as. |

106 changes: 106 additions & 0 deletions lib/resty/ldap.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
local log = ngx.log
local ERR = ngx.ERR
local client = require "resty.ldap.client"


local tostring = tostring

local default_conf = {
timeout = 10000,
start_tls = false,
ldap_host = "localhost",
ldap_port = 389,
ldaps = false,
base_dn = "ou=users,dc=example,dc=org",
attribute = "cn",
keepalive = 60000,
}

local function set_conf_default_values(conf)
for k, v in pairs(default_conf) do
if conf[k] == nil then
conf[k] = v
end
end
end


local function resolve_tls_verify(conf)
if conf.tls_verify ~= nil then
return conf.tls_verify
end
if conf.verify_ldap_host ~= nil then
return conf.verify_ldap_host
end
return false
end


-- RFC 4514 s2.4 escaping for an RDN value; the bind DN below is built from a
-- client-supplied username. Not filter.escape(), which is RFC 4515 and leaves
-- ',' and '+' alone.
local function escape_dn_value(value)
local lead = value:sub(1, 1)
-- a lone leading space is covered by the lead rule alone
local trail = #value > 1 and value:sub(-1) or ""

local out = value:gsub('([\\",+<>;=])', "\\%1"):gsub("%z", "\\00")

if trail == " " then
out = out:sub(1, -2) .. "\\ "
end
if lead == " " or lead == "#" then
out = "\\" .. out
end

return out
end


local _M = {}


function _M.ldap_authenticate(given_username, given_password, conf)
set_conf_default_values(conf)

-- same coercion contract as protocol.simple_bind_request
if type(given_username) == "number" then
given_username = tostring(given_username)
end
if type(given_username) ~= "string" then
return false, "bind username must be a string"
end

local cli = client:new(conf.ldap_host, conf.ldap_port, {
socket_timeout = conf.timeout,
keepalive_timeout = conf.keepalive,
start_tls = conf.start_tls,
ldaps = conf.ldaps,
ssl_verify = resolve_tls_verify(conf),
})

local ok, err = cli:connect()
if not ok then
log(ERR, "failed to connect to ", conf.ldap_host, ":",
tostring(conf.ldap_port), ": ", err)
return nil, err
end

local who = conf.attribute .. "=" .. escape_dn_value(given_username) ..
"," .. conf.base_dn
local is_authenticated, bind_err = cli:simple_bind(who, given_password)

-- the socket carried a Bind attempt: always close it, never pool it
cli:close()

if is_authenticated == nil then
-- transport failure mid-bind; the client already dropped the socket
return nil, bind_err
end

-- who: the canonical escaped bind DN, for callers that key identity on it
return is_authenticated, bind_err, who
end


return _M
Loading