Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
1f4de6d
feat(ldap): LDAP authentication with group-based access control and s…
penggaolai Feb 24, 2026
82f669a
test(ldap): comprehensive test suite — 196 tests across 4 suites
penggaolai Feb 24, 2026
8b6e65a
fix(lint): resolve all Biome lint errors in test mocks and LDAP modules
penggaolai Feb 24, 2026
7f3fa89
ci: retrigger build (transient cypress image failure)
penggaolai Feb 24, 2026
43ff83a
fix(ldap): semaphore leak in withServiceClient — use try/finally to g…
penggaolai Feb 25, 2026
f5a8cee
fix(ldap): rate-limit user bind connections via login semaphore
penggaolai Feb 25, 2026
6d3cbd7
fix(ldap): add UNIQUE constraint on user email + skip redundant login…
penggaolai Feb 25, 2026
005cde6
Merge remote-tracking branch 'github/develop' into feature/ldap-auth
penggaolai Feb 25, 2026
3920d35
fix(ldap): replace plain UNIQUE email index with partial index for so…
penggaolai Feb 25, 2026
ba3e005
fix(ldap): make email_active migration idempotent for MySQL CI
penggaolai Feb 25, 2026
1cc87cc
fix(ldap): MySQL requires STORED generated column for indexing
penggaolai Feb 25, 2026
a39908b
fix(ldap): omit MySQL email_active generated column from API responses
penggaolai Feb 25, 2026
53dada6
fix(ldap): strip email_active at model level to prevent leak via JOINs
penggaolai Feb 25, 2026
08317f7
fix: align frontend field names bindDN/groupDN to match API schema
penggaolai Feb 26, 2026
f77ced1
harden: destroy raw socket on STARTTLS/bind failure in LdapClient.create
penggaolai Feb 26, 2026
ae9dc18
ldap-client: add debug logging to idle connection reaper
penggaolai Feb 26, 2026
348774c
chore: retrigger CI
penggaolai Feb 26, 2026
cb414b1
feat(ldap): improved validation errors, env override UI, humps bypass…
penggaolai Feb 26, 2026
30632a3
fix: preserve AJV-compatible error format for pattern validation
penggaolai Feb 26, 2026
6cac86c
ci: trigger build #17
penggaolai Feb 26, 2026
d9b5a5f
ci: retrigger build #18 (MySQL PowerDNS flaky test)
penggaolai Feb 26, 2026
c56a304
feat(ldap): use objectGUID/entryUUID as primary identifier instead of…
Mar 1, 2026
9bbcc6c
fix(ldap-client): await in-flight page handler before resolving searc…
penggaolai Mar 1, 2026
afbdb06
fix(ldap-sync): unify disable/enable decision — group check is not an…
penggaolai Mar 1, 2026
0540b05
fix: resolve biome lint errors — backfillGuids inside object, unused …
penggaolai Mar 1, 2026
61a7a8f
fix: rename escape→escapeLdap to avoid shadowing global (biome)
penggaolai Mar 1, 2026
04cd3c5
fix: guard ldap_guid migration against duplicate column (CI re-uses DB)
penggaolai Mar 1, 2026
2d12d00
fix: correct AD objectGUID binary encoding to standard GUID format
penggaolai Mar 1, 2026
b94fa8e
Merge remote-tracking branch 'upstream/develop' into feature/ldap-auth
penggaolai Mar 5, 2026
b8b9d2e
fix(ldap-sync): existingId scoping + disabled TDZ in syncAllUsers
penggaolai Mar 5, 2026
8669c94
fix(ldap-sync): fix NOT NULL constraint on auth.secret for MySQL/Post…
penggaolai Mar 5, 2026
3d268a2
fix(ldap-sync): allow GUID-based provisioning for email-less AD users…
penggaolai Mar 5, 2026
9ded20a
fix: use attr.buffers for binary LDAP attrs to fix objectGUID parsing
penggaolai Mar 5, 2026
8c3cc46
fix: remove double syncUserGroups call + add LDAP filter escaping in …
penggaolai Mar 5, 2026
3d58f85
fix: filter out service accounts from LDAP sync (Bug #3)
Mar 13, 2026
c8155e0
fix(ldap-settings): remove duplicate closing brace that broke object …
Mar 13, 2026
eedeed3
fix(lint): biome compliance — Number.parseInt + no parameter reassign…
Mar 13, 2026
353f902
fix(lint): remaining biome errors — unused vars, template literals, N…
Mar 13, 2026
8c0fd18
fix(migration): quote reserved word 'user' in PostgreSQL query
Mar 13, 2026
17ba0cd
refactor: tighten JSDoc comments across LDAP source + test files
Mar 13, 2026
70354bf
docs: add final results to PR audit report
Mar 13, 2026
a70c29b
Delete docs/PR-AUDIT-REPORT.md
Mar 13, 2026
bcbdcd4
fix: cross-DB migration + clean PR diff
Mar 13, 2026
fdd7863
fix(lint): add rel="noopener noreferrer" to target="_blank" link
Mar 14, 2026
3eb8ae8
fix(migration): restore page_size stub to prevent corrupt migration e…
Mar 18, 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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ test/node_modules
*/node_modules
docker/dev/dnsrouter-config.json.tmp
docker/dev/resolv.conf
backend/package-lock.json
package-lock.json
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,47 @@ so that the barrier for entry here is low.
- Access Lists and basic HTTP Authentication for your hosts
- Advanced Nginx configuration available for super users
- User management, permissions and audit log
- **LDAP / Active Directory authentication** with JIT user provisioning and group-based access control

## LDAP / Active Directory Authentication

NPM supports LDAP authentication, allowing your team to log in with their existing directory credentials.

**Key features:**
- Works with **Active Directory**, **OpenLDAP**, **FreeIPA**, and compatible servers
- **Group-based access control** — map LDAP groups to NPM admin / user roles
- **Just-in-time provisioning** — NPM accounts are created automatically on first login
- **Environment variable configuration** — ideal for Docker and Kubernetes deployments
- Supports **LDAPS** and **STARTTLS** for encrypted connections
- **Sync Now** button to force-refresh all LDAP user permissions

**Quick start (Docker):**

```yaml
services:
app:
image: 'docker.io/jc21/nginx-proxy-manager:latest'
environment:
LDAP_ENABLED: "true"
LDAP_SERVER_URL: "ldap://your-ldap-server:389"
LDAP_BIND_DN: "cn=service,dc=example,dc=com"
LDAP_BIND_PASSWORD: "service-password"
LDAP_SEARCH_BASE: "dc=example,dc=com"
LDAP_USER_ATTR: "uid" # use sAMAccountName for Active Directory
LDAP_ADMIN_GROUP: "cn=npm-admins,ou=Groups,dc=example,dc=com"
LDAP_USER_GROUP: "cn=npm-users,ou=Groups,dc=example,dc=com"
```
📖 **Full documentation:** [docs/ldap-authentication.md](docs/ldap-authentication.md)
Covers:
- [Active Directory setup guide](docs/ldap-authentication.md#active-directory-setup)
- [OpenLDAP setup guide](docs/ldap-authentication.md#openldap-setup)
- [FreeIPA setup guide](docs/ldap-authentication.md#freeipa-setup)
- [Group-based access control](docs/ldap-authentication.md#group-based-access-control)
- [TLS / STARTTLS configuration](docs/ldap-authentication.md#tls-and-starttls)
- [Troubleshooting](docs/ldap-authentication.md#troubleshooting)
- [Environment variable reference](docs/ldap-authentication.md#environment-variable-reference)
::: warning
`armv7` is no longer supported in version 2.14+. This is due to Nodejs dropping support for armhf. Please
Expand Down
17 changes: 17 additions & 0 deletions backend/__mocks__/bcrypt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Stub for bcrypt — replaces the native addon-based package when
* node-gyp-build or native bindings are unavailable in the test env.
*
* Tests that exercise real password hashing should use the real module;
* for all other tests the models are fully mocked anyway.
*/
const FAKE_HASH = "$2b$10$TESTHASHfakevalueusedinstubonlyXXXXXXXXXXXXXXXXXX";

export default {
hash: async (_data, _saltOrRounds) => FAKE_HASH,
hashSync: (_data, _saltOrRounds) => FAKE_HASH,
compare: async (_data, _hash) => true,
compareSync: (_data, _hash) => true,
genSalt: async (_rounds) => "$2b$10$TESTSALTfakevalueXXXXXXXXXX",
genSaltSync: (_rounds) => "$2b$10$TESTSALTfakevalueXXXXXXXXXX",
};
42 changes: 42 additions & 0 deletions backend/__mocks__/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Stub for backend/lib/config.js — used in Jest ESM test environment.
* Provides no-op implementations of configuration helpers so that models
* can be imported without triggering real file-system or key-generation
* side-effects.
*/

export const configure = () => {};
export const isSqlite = () => false;
export const isMysql = () => false;
export const isPostgres = () => false;
export const isCI = () => false;
export const isDebugMode = () => false;
export const configGet = (_key) => ({});
export const configHas = (_key) => false;
export const getKeys = () => ({ private: "", public: "" });
export const getPrivateKey = () => "";
export const getPublicKey = () => "";
export const generateKeys = async () => {};
export const getConfig = () => ({});
export const getSetting = () => null;
export const useLetsencryptStaging = () => false;
export const useLetsencryptServer = () => "https://acme-v02.api.letsencrypt.org/directory";

export default {
configure,
isSqlite,
isMysql,
isPostgres,
isCI,
isDebugMode,
configGet,
configHas,
getKeys,
getPrivateKey,
getPublicKey,
generateKeys,
getConfig,
getSetting,
useLetsencryptStaging,
useLetsencryptServer,
};
7 changes: 7 additions & 0 deletions backend/__mocks__/db.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Stub for backend/db.js — used in Jest ESM test environment
* to avoid loading knex and its transitive dependencies.
*/

const db = () => null;
export default db;
74 changes: 74 additions & 0 deletions backend/__mocks__/lodash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* ESM stub for lodash — used in Jest ESM test environment.
* The real lodash is CJS and may not resolve correctly through Jest's ESM resolver.
*/

const noop = () => {};

const _ = {
isArray: Array.isArray,
isString: (v) => typeof v === "string",
isObject: (v) => v !== null && typeof v === "object",
isFunction: (v) => typeof v === "function",
isNumber: (v) => typeof v === "number",
isUndefined:(v) => v === undefined,
isNull: (v) => v === null,
isNil: (v) => v == null,
isEmpty: (v) => !v || (Array.isArray(v) ? v.length === 0 : typeof v === "object" ? Object.keys(v).length === 0 : false),
merge: (target, ...sources) => Object.assign(target ?? {}, ...sources),
omit: (obj, keys) => {
const result = { ...obj };
const ks = Array.isArray(keys) ? keys : [keys];
for (const k of ks) delete result[k];
return result;
},
pick: (obj, keys) => {
const result = {};
const ks = Array.isArray(keys) ? keys : [keys];
for (const k of ks) if (k in obj) result[k] = obj[k];
return result;
},
clone: (v) => Array.isArray(v) ? [...v] : typeof v === "object" && v ? { ...v } : v,
cloneDeep: (v) => JSON.parse(JSON.stringify(v)),
assign: Object.assign,
forEach: (collection, fn) => {
if (Array.isArray(collection)) { collection.forEach(fn); }
else if (collection && typeof collection === "object") { Object.entries(collection).forEach(([k, v]) => { fn(v, k); }); }
},
map: (collection, fn) => {
if (Array.isArray(collection)) return collection.map(fn);
if (collection && typeof collection === "object") return Object.entries(collection).map(([k, v]) => fn(v, k));
return [];
},
filter: (arr, fn) => Array.isArray(arr) ? arr.filter(fn) : [],
find: (arr, fn) => Array.isArray(arr) ? arr.find(fn) : undefined,
reduce: (arr, fn, init) => Array.isArray(arr) ? arr.reduce(fn, init) : init,
some: (arr, fn) => Array.isArray(arr) ? arr.some(fn) : false,
every: (arr, fn) => Array.isArray(arr) ? arr.every(fn) : true,
keys: Object.keys,
values: Object.values,
entries: Object.entries,
get: (obj, path, def) => {
if (!obj) return def;
const parts = typeof path === "string" ? path.split(".") : path;
let cur = obj;
for (const p of parts) { if (cur == null) return def; cur = cur[p]; }
return cur === undefined ? def : cur;
},
set: (obj, path, val) => {
const parts = typeof path === "string" ? path.split(".") : path;
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) cur[parts[i]] = {}; cur = cur[parts[i]]; }
cur[parts[parts.length - 1]] = val;
return obj;
},
has: (obj, key) => Object.hasOwn(obj ?? {}, key),
defaults: (obj, ...sources) => { for (const src of sources) for (const [k, v] of Object.entries(src)) if (obj[k] === undefined) obj[k] = v; return obj; },
flatten: (arr) => arr.flat(),
flatMap: (arr, fn) => arr.flatMap(fn),
uniq: (arr) => [...new Set(arr)],
sortBy: (arr, fn) => [...arr].sort((a, b) => { const va = typeof fn === "function" ? fn(a) : a[fn]; const vb = typeof fn === "function" ? fn(b) : b[fn]; return va < vb ? -1 : va > vb ? 1 : 0; }),
noop,
};

export default _;
32 changes: 32 additions & 0 deletions backend/__mocks__/moment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Minimal stub for moment.js.
* Provides the API surface used by lib/helpers.js (parseDatePeriod).
*/

const momentObj = {
add: function() { return this; },
subtract: function() { return this; },
toDate: () => new Date(),
toISOString: () => new Date().toISOString(),
isValid: () => true,
valueOf: () => Date.now(),
};

function moment(input) {
return {
...momentObj,
clone: () => moment(input),
};
}

moment.duration = (_value, _unit) => ({
asMilliseconds: () => 86400000,
toISOString: () => "P1D",
});

moment.isMoment = (obj) => obj && typeof obj.toDate === "function";
moment.utc = (input) => moment(input);
moment.unix = (ts) => moment(new Date(ts * 1000));
moment.ISO_8601 = "ISO_8601";

export default moment;
15 changes: 15 additions & 0 deletions backend/__mocks__/node-rsa.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Minimal stub for node-rsa — used when the real package's dependencies
* (asn1, etc.) are not installed in the test environment.
*/
export default class NodeRSA {
// biome-ignore lint/complexity/noUselessConstructor: mock stub
constructor() {}
generateKeyPair() { return this; }
exportKey() { return "-----BEGIN RSA KEY-----\nMOCK\n-----END RSA KEY-----"; }
importKey() { return this; }
sign() { return Buffer.from("mock-signature"); }
verify() { return true; }
encrypt() { return Buffer.from("mock-encrypted"); }
decrypt() { return Buffer.from("mock-decrypted"); }
}
56 changes: 56 additions & 0 deletions backend/__mocks__/objection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Minimal stub for the objection.js ORM — used only when the real package
* is not installed in the test environment.
*
* biome-ignore: `this` in static methods is intentional for query-builder chaining.
*/

// biome-ignore lint/complexity/noStaticOnlyClass: mock mirrors Objection.js Model API
export class Model {
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static query() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static where() { return this; }
static first() { return Promise.resolve(null); }
static findById() { return Promise.resolve(null); }
static insertAndFetch() { return Promise.resolve({}); }
static patch() { return Promise.resolve(1); }
static patchAndFetchById() { return Promise.resolve({}); }
static insert() { return Promise.resolve({}); }
static delete() { return Promise.resolve(1); }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static count() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static andWhere() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static orWhere() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static allowGraph() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static withGraphFetched() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static groupBy() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static orderBy() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static whereIn() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static limit() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static offset() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static select() { return this; }
static knex(_db) { return null; }
static raw(sql, ...bindings) { return { sql, bindings, toKnexRaw: () => sql }; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static relatedQuery() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static for() { return this; }
// biome-ignore lint/complexity/noThisInStatic: intentional for chaining
static modify() { return this; }
}

// objection helper functions used in queries
export const ref = (expr) => ({ expression: expr });
export const raw = (sql, ...bindings) => ({ sql, bindings });
export const fn = { now: () => new Date() };
40 changes: 40 additions & 0 deletions backend/__mocks__/signale.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Manual stub for the `signale` logging library.
*
* Used via moduleNameMapper so it must NOT reference jest globals.
* Provides the Signale class and default logger methods as no-ops.
*/

const noop = () => {};

const makeLogger = () => ({
debug: noop,
info: noop,
warn: noop,
error: noop,
log: noop,
fatal: noop,
trace: noop,
start: noop,
success: noop,
await: noop,
note: noop,
pause: noop,
complete: noop,
pending: noop,
star: noop,
watch: noop,
});

class Signale {
constructor(_opts) {
Object.assign(this, makeLogger());
}
}

const defaultLogger = makeLogger();

export default {
Signale,
...defaultLogger,
};
14 changes: 14 additions & 0 deletions backend/__mocks__/tarn.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Minimal stub for tarn (connection pool library, transitive dep of knex).
*/
export class Pool {
// biome-ignore lint/complexity/noUselessConstructor: mock stub accepts opts
constructor(_opts) {}
acquire() { return { promise: Promise.resolve({}) }; }
release() {}
destroy() { return Promise.resolve(); }
numUsed() { return 0; }
numFree() { return 0; }
numPendingAcquires() { return 0; }
numPendingCreates() { return 0; }
}
16 changes: 16 additions & 0 deletions backend/__tests__/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Backend Unit Tests

Tests use **Jest** with ESM support. Run from `backend/`:

```bash
NODE_OPTIONS="--experimental-vm-modules" npx jest --testPathPattern="__tests__/ldap"
```

| File | Module under test | Coverage |
|------|-------------------|----------|
| `ldap/ldap-client.test.js` | `lib/ldap-client.js` | Connection, bind, search, pool, TLS, error mapping |
| `ldap/ldap-internal.test.js` | `internal/ldap.js` | testConnection, searchUser, authenticateUser, normalizeUser |
| `ldap/ldap-sync.test.js` | `internal/ldap-sync.js` | JIT provisioning, group sync, account disable, syncAllUsers |
| `ldap/ldap-env.test.js` | `lib/ldap-env.js` | Env var overrides, boolean parsing, rowToLdapClientConfig |
| `ldap/objectguid.test.js` | `internal/ldap.js` | objectGUID parsing, LDAP filter encoding, UTF-8 regression |
| `validator/api-validator.test.js` | `lib/validator/api.js` | Validation error formatting |
Loading