Add LDAP, SAML and OAuth authentication providers - #5797
Conversation
Users can now sign in with credentials from an existing directory or identity provider, configured either under Users -> Authentication Providers or entirely through environment variables. LDAP reuses the normal login form, so directory users type their username or email like everyone else. SAML and OAuth add a "Continue with ..." button that redirects out to the provider and back. Backend: - New auth_provider table, plus provider_id/identifier on auth so a user can be linked back to their upstream account - One driver per protocol in lib/auth, with lib/auth/provision.js turning a verified identity into a local user - Providers described by AUTH_* environment variables are reconciled into the database on boot and shown read only in the interface - Public endpoints under /auth for the redirect flows, admin CRUD under /auth-providers, both with full OpenAPI schemas Behaviour: - Auto-creating users is opt in per provider; otherwise an administrator has to create the account first and it is matched by email address - An optional admin group grants the admin role on every sign in, and revokes it again when someone leaves the group - Local password sign in can be switched off once a provider is enabled, guarded so that it cannot leave an instance with no way in. AUTH_DISABLE_LOCAL overrides the stored setting either way Secrets are write only: they are never returned by the API, are kept when an update omits them, and stay out of the audit log. OAuth uses the authorization code flow with PKCE, a single use state and a nonce, and verifies ID tokens against the provider's JWKS. Completed SSO logins hand the frontend a single use code rather than putting a token in a URL. Also fixes twoFactor.isEnabled(), which threw for any user without a local password row and so would have rejected every external login.
… sync and tests
Ports the parts of Wadera's LDAP PR that carry real value, adapted to the
multi-provider model rather than its single global LDAP config.
Stable directory identifiers:
- Accounts are now keyed on objectGUID (Active Directory) or entryUUID
(OpenLDAP), normalised to one canonical form in lib/auth/guid.js, and
only fall back to the DN when a directory publishes neither
- A DN is not durable: renaming somebody, or moving them between
organisational units, changes it and used to strand their account
- auth.identifier is nullable with a unique index on
(provider_id, identifier), so two accounts cannot bind to the same
directory object. It stays NULL for local password rows, which every
supported engine treats as distinct
Directory sync (lib/auth/sync.js), per provider rather than global:
- Walks the directory on a schedule, creating accounts before anyone
signs in and refreshing group-driven roles without waiting for a login
- Optionally disables accounts whose entry has gone away, guarded so the
last remaining administrator is never disabled and so a run that
returns nothing at all disables nobody
- Timers are reconciled after every provider change, and on boot
- Sync now button and settings in the interface, plus
GET/POST /api/auth-providers/{id}/sync
Other LDAP work carried over:
- Paged search, so directories past the server's result cap (1000 by
default in Active Directory) enumerate fully
- login_attributes as a friendlier alternative to writing a filter
- POST /api/auth-providers/{id}/test-credentials verifies a real username
and password and reports the attributes that came back
An Auth column in the Users list shows where each account signs in from,
naming the actual provider rather than a generic LDAP badge, since an
instance can have several.
Kept ldapts rather than adopting ldapjs as the source PR does: ldapjs was
last released in December 2023 and its repository is archived, and ldapts
covers paging and binary attributes natively.
Adds vitest to the backend with 76 tests over the parts worth pinning:
GUID byte order, LDAP filter escaping and injection, provider config
normalisation and secret redaction, environment parsing, and role mapping.
Vitest rather than the source PR's jest, to match the frontend and avoid
--experimental-vm-modules.
The existing Test button lives on the providers list, so it only works once a provider has been saved. That is the wrong way round: you find out whether the server details are right only after committing to them, and for a brand new provider there is nothing to test against at all. Adds a Test connection button inside the settings dialog, placed directly after the fields it exercises: server URL and bind credentials for LDAP, issuer and client for OAuth, sign-in URL and certificate for SAML. The result appears inline next to those fields, so the rest of the form can be filled in knowing the connection already works. POST /api/auth-providers/test checks settings supplied in the request without writing anything. Secrets are never returned to the client, so it accepts an optional provider id and merges the stored secret in for any field left blank, exactly as an update does. A failure comes back as valid:false with a message rather than an error status, since the caller wants to render it beside the inputs rather than treat it as a fault. Also translates LDAP failures into something actionable. ldapts reports protocol errors as a bare result code, so a wrong bind password produced only " Code: 0x31"; it now reads "Invalid credentials — check the bind DN and password", with the same treatment for a missing base DN, a refused or unresolvable host, and an untrusted TLS certificate. The display name is deliberately not required by this endpoint. Requiring one would defeat the purpose, because the connection is usually checked before the provider has been named.
Answers the question raised on PR NginxProxyManager#5345: the providers only reached the admin interface, so protecting an actual proxied site still meant keeping a second list of usernames and passwords by hand, with no way to say "anyone in this directory group". An access list gains a Providers tab. Tick the directories it should accept and visitors are prompted for a username and password exactly as before, except the credentials are checked against the directory rather than the list's own entries. Restrict to groups narrows that to members of named groups, one per line, which is the allow-list that was asked for. Only LDAP is offered. SAML and OAuth sign people in by redirecting a browser to the identity provider, which cannot happen for an arbitrary proxied request, and offering them would only mislead. A directory will not hand over password hashes, so its users cannot go into the htpasswd file nginx normally uses. Lists with no providers are therefore left exactly as they were, and only those that opt in switch to asking the backend per request: - auth_request against an internal location, with the challenge carried back from the subrequest so browsers still prompt. A 401 raised by the proxied application itself has no such header and passes through untouched - GET /access-lists/{id}/verify does the checking. Unauthenticated by design: it is the visitor's credentials being examined, and the answer never reveals whether a username exists - X-Auth-User and X-Auth-Email go to the proxied application on success A check in front of every request has to be cheap, so decisions are cached for five minutes, refusals for thirty seconds, and the whole cache is dropped when a list is saved so that revoking access takes effect at once. The cache key is a digest of the credentials rather than the credentials themselves. An unreachable directory refuses the request: a protected site does not fall open because a server is down. Entries typed into the list keep working alongside a provider, which leaves a usable break-glass account when the directory is unavailable. 20 tests cover the group matching, the local and provider paths, provider failures, and that the cache never lets one password, list or user stand in for another. Verified against a live directory, and the generated config checked with nginx -t; NPM's nginx is built with --with-http_auth_request_module.
CI runs vacuum over the compiled schema with -n=warn, so a warning fails the build. The endpoints added by this branch tripped oas3-missing-example eleven times, which is what broke SwaggerSchema.cy.js on PR NginxProxyManager#5797. It was the only failing spec in the run. Adds an example to every property and media type that lacked one: - auth-sync-result: started_on, finished_on, error - auth-login-options: the providers array - auth-providers/test: the id it may be given, and the error it may return - auth-providers/{id}/sync: examples for both the GET status and the POST result, including a populated last_result - auth-providers/{id}/test-credentials: the attributes reported back - auth/exchange: both arms of the response, a token and a 2FA challenge - auth/{id}/callback: the posted SAML form Verified with the same tool and ruleset the pipeline uses, vacuum 0.26.4 lint -b -q -d -a --no-clip -n=warn, against the schema served from /api/schema: 100/100, exit 0.
The advanced configuration page is where people look for environment
variables, and it only carried a three line example with a link
elsewhere. It now lists all 63 of them, grouped into global settings, the
options common to every provider type, and one table each for LDAP, SAML
and OAuth, with defaults and a note on what each does.
The dedicated page turned out to be incomplete too. Eight variables added
later were never written up: AUTH_LDAP_LOGIN_ATTRIBUTES,
AUTH_LDAP_PAGE_SIZE and the five AUTH_LDAP_SYNC_* ones. Directory sync
had no prose at all, only a Sync button in the interface, so there is now
a section explaining what it does, that enabling it provisions everyone
the filter matches, and which guards stop it disabling an organisation.
Also fixes placeholders being swallowed by the docs renderer. VitePress
runs Vue over page content, so {{username}} and {{dn}} inside inline code
were interpolated away: the default user filter rendered as
"(|(uid=)(mail=))" and anyone copying it got something that matches
nothing. Those spans are now <code v-pre>, with literal pipes written as
&NginxProxyManager#124; so they cannot be mistaken for table separators. Fenced blocks
were never affected and are left as they are.
Checked by rebuilding the site and comparing the variables the code reads
against both pages: 63 of 63 covered on each, and no placeholder lost.
|
I'm excited to see this in action. However im in a hotel while renovating both the house I'm selling and moving into. Only one of my racks is operational, so I don't have infrastructure emplaced to test this. It may be a couple of weeks. Access list providers is an awesome feature! Thank you! Please don't hold up this PR on my account, but I'd like to ask some questions based on the description. For edge environments with constrained memory and slow I/O (e.g., running on embedded boards or flash storage), can the scheduled directory sync daemon be completely disabled per-provider in favor of pure just-in-time (JIT) authentication/binding? If disabled, does JIT auto-provisioning still respect group-to-role mappings without persisting the entire LDAP directory tree locally? How does the 'last remaining administrator' guard compute admin quorum during a sync pass? Specifically:
The email-matching takeover is a critical risk for deployments using public/semi-public OAuth providers. Rather than carrying this as a known issue, can we gate account linking behind an explicit configuration toggle ( |
Removing a provider used to strand everyone it created. Their link kept
pointing at a provider that no longer existed and they hold no password of
their own, so nobody could sign in as them and nothing said why.
Deleting one in the interface now asks. The dialog names the provider,
says how many accounts came from it, and offers two outcomes:
- Keep them as local accounts. The link is dropped, the accounts stay with
their hosts, permissions and ownership intact, and an administrator can
set them a password from the Users screen.
- Delete them. It says up front how many would actually go, because two
kinds are always kept: anyone who can still sign in another way, with a
password or through a second provider, and the last remaining
administrator.
Removing a provider's environment variables converts its accounts to local
automatically. Nobody confirmed anything in that case, so a variable
disappearing from a compose file must not quietly take people's accounts
with it. The log names the provider and the number converted.
The API takes ?users=convert|delete on the delete, defaulting to convert:
losing access should never be the consequence of leaving a parameter off.
GET /api/auth-providers/{id}/users reports how many accounts a provider
owns and how many of those have no other way in, which is what the dialog
shows before anyone commits.
10 tests cover the decisions, since this is the destructive path: that
convert is the default, that other providers' links are untouched, that a
second sign-in method or being the last administrator spares an account,
and that with two administrators one goes and the other stays.
Verified against the running stack: deleting a provider removed only the
account that had no other way in, converting left everyone in place, and
an administrator setting a password on a converted account restored their
sign in. Removing AUTH_OAUTH_* converted its account and kept it.
Resolves the conflicts the pull request was showing. Upstream had moved on with dependency bumps, a repository-wide biome reformat, and the fix for PermissionError being constructed without new. Five files conflicted: - internal/token.js — upstream reformatted the body of getTokenFromEmail that this branch had already split into verifyLocalPassword and issueForUser. Kept the split; their other changes to the file, including tokenData.scope?.[0], merged cleanly on their own. - package.json — took upstream's bumps (json-schema-ref-parser 16, knex 3.3.0, liquidjs 10.29.0, biome 2.5.10) alongside the dependencies this branch adds (ldapts, node-saml, vitest). - setup.js — upstream hoisted the fs import to the top, which is the lint warning this branch had been leaving behind. Kept that and the auth provider imports. - yarn.lock — regenerated from the merged package.json rather than reconciled by hand. - Login/index.tsx — upstream collapsed the card body onto one line; this branch replaced that expression with renderBody(), which also covers the provider buttons and the code exchange. Kept ours. One new lint warning from the newer biome, in code this branch owns: isLocalAuthEnabled now reads row?.value !== "disabled". Absent rows still resolve to enabled, so it fails open exactly as before. Verified after merging: 112 tests pass, backend and frontend lint are clean, the schema validates, the frontend builds, and the running stack still signs in locally and through LDAP. Also confirmed upstream's fix is live, with a non-admin now getting 403 rather than 404.
Answers the review on NginxProxyManager#5797. Linking an external identity to an existing local account by email address was unconditional, and happened before the auto_create_user check, so a provider that lets somebody choose their own address could be used to take over any account here, administrators included. Turning auto-creation off did not prevent it. Linking now requires link_by_email on the provider, off by default, and for OAuth the provider must additionally report email_verified: true. When it is off and the address already belongs to an account, the sign in is refused with an explanation rather than silently adopting it. Testing that surfaced a related bug: a transient SAML NameID was being used as the stable identifier. Since the IdP issues a different one per session, every login looked like a new person and only the implicit email match made it work at all. A transient NameID now falls back to the email address, or to a configurable identifier attribute. Also closes the ways an instance could end up with nobody able to sign in: - A failed LDAP group lookup reported "no groups", which revoked admin from everyone it happened to. It now reports "unknown" and roles are left alone. Access lists still deny on unknown membership, which is the right default when guarding a resource rather than assigning a role. - The "last remaining administrator" guards counted anybody holding the role. An administrator whose only credential is a password is no fallback once local sign in is off, nor is one who only uses the provider being removed. Both guards now ask whether the other administrator could actually sign in. - Turning off local sign in now requires that an administrator has already signed in through a provider, not merely that one is configured. - Removing the last provider turns local sign in back on. And two smaller ones: SAML assertions must now name the request they answer, so a captured assertion cannot be replayed (this rules out IdP-initiated sign in), and the OAuth callback no longer reflects the provider's error text into the login page before any validation has happened.
|
Thanks — no rush, and good luck with the move. All three are fixed in 58209f8. Two of your questions found real bugs. Sync on small hardware. Sync is per-provider and off by default. When it's off, no timer runs at all. The directory is never copied locally either way — the only rows written are a user and a link, for the people who actually sign in. Group-to-role mapping happens at sign-in, so Administrator group works exactly the same with sync off. Admin quorum. You were right to ask. It counted anyone who held the admin role, local or federated. But holding the role isn't the same as being able to sign in. With Separately, and worse: if the LDAP group search failed, we treated it as "this user is in no groups". With an admin group configured, that means "not an admin" — so a brief LDAP hiccup quietly demoted every administrator who signed in during it. A failed lookup now leaves roles untouched. On lockout: you can no longer switch off local sign in until an admin has actually signed in through a provider once. Removing the last provider switches local sign in back on. And if One thing I can't fix: if your IdP goes down while local sign in is off, nobody can get in until it's back or you restart with Email matching. Done as you suggested. Heads up if you're already testing this branch: linking by email was the only reason SAML worked. The stable ID we stored was the Two smaller fixes went in too: a captured SAML assertion can no longer be replayed (which means IdP-initiated sign in isn't supported — it's in the docs), and the OAuth callback no longer prints the provider's error text onto the login page. I tested against a real stack, not just unit tests: LDAP, SAML and OAuth all still sign in, and two SAML logins in a row now land on one account instead of two. 126 backend tests pass. |
|
Docker Image for build 6 is available on DockerHub: Note Ensure you backup your NPM instance before testing this image! Especially if there are database changes. Warning Changes and additions to DNS Providers require verification by at least 2 members of the community! |
|
Here are some screenshots of what this looks like 😉 Login page Providers that sign in by redirect get a button each. The password form stays underneath, and disappears entirely if you turn local sign in off. LDAP has no button — it uses the normal username and password form, so directory users sign in the same way everyone else does.
Users The list has an Auth column showing where each account comes from:
Authentication Providers A second tab under Users. Each row shows the type, whether it's enabled, and how it provisions people — whether accounts are created on first sign in, and how often the directory is synced. Providers configured through environment variables are marked From environment and are read-only here, since the container's config owns them. Test checks the connection without saving anything, so you can confirm a server and bind account work before filling in the rest. Allow email and password sign in is the switch that turns off the password form.
Provider configuration Fields depend on the type. Secrets are write-only: they're never sent back to the browser, so an existing value shows as set rather than being displayed, and leaving the field blank keeps it. Access lists Request from #5345. An access list gets a Providers tab where you tick the directories it should accept. Visitors are still prompted for a username and password exactly as before — those credentials are just checked against the directory instead of only the list. Restrict to groups narrows it further, so a proxied site can be limited to one group rather than everyone in the directory. Leave it empty to accept anyone the directory authenticates. Usernames typed on the Authorizations tab keep working either way, so an existing list carries on unchanged and you can mix the two. Only LDAP appears here. SAML and OAuth sign people in by redirecting a browser, which can't happen inside the subrequest nginx makes to check a proxied request.
|
|
Awesome! I am reading and since I'm mobile, maybe I'm missing something.
What is this one?
|
I ran exactly your scenario: built-in account with a password, then linked to LDAP by signing in through it:
So disabling someone upstream closes the SSO door but not the password door. I hope this answers your question 😉 |







Why
There are long-standing requests for LDAP sign in, and separate ones for SAML
and other identity providers. Rather than bolting on a single protocol, this
adds a general authentication provider concept so the same plumbing covers
all three and leaves room for more.
It also picks up the work from #5345 (thanks @Wadera) and answers the question
@adamoutler raised there: providers are not limited to the admin dashboard, they
can protect proxied sites through access lists too.
LDAP deliberately reuses the existing login form, so directory users type their
username or email like everyone else and that page does not change for them.
Providers are configured under Users → Authentication Providers, or entirely
through
AUTH_*environment variables for container deployments.What is in it
Signing in. Per-provider opt-in for creating accounts on first sign in;
otherwise an administrator creates the account and it is matched by email. An
optional admin group grants the
adminrole on every sign in and revokes it whensomebody leaves the group. Local password sign in can be switched off once a
provider is enabled, guarded so an instance cannot be left with no way in, with
AUTH_DISABLE_LOCALoverriding the stored setting either way.Stable directory identifiers. Accounts are keyed on
objectGUID(ActiveDirectory) or
entryUUID(OpenLDAP) rather than the DN, because a DN changeswhen somebody is renamed or moved between organisational units and used to
strand their account.
auth.identifiercarries a unique index per provider sotwo accounts cannot bind to the same directory object.
Directory sync, per provider. Walks the directory on a schedule so accounts
exist before anyone signs in and group-driven roles stay current without waiting
for a login. Optionally disables accounts whose entry has gone away, guarded so
the last remaining administrator is never disabled and a run returning nothing
at all disables nobody. Paged search handles directories past the server's
result cap.
Access lists gain a Providers tab, so a proxied site can accept directory
accounts, optionally restricted to named groups. Lists with no providers keep
the existing htpasswd path untouched; only those that opt in switch to an
auth_requestsubrequest againstGET /access-lists/{id}/verify. Decisions arecached (5 minutes, refusals 30 seconds, dropped when the list is saved) because
that check sits in front of every request. An unreachable directory refuses, so
a protected site does not fall open.
X-Auth-UserandX-Auth-Emailare passedto the proxied application.
Testing a provider before committing to it. A Test connection button sits
inside the settings dialog next to the fields it exercises, so the connection can
be confirmed before the rest is filled in. LDAP protocol errors are translated,
so a wrong bind password reads "Invalid credentials — check the bind DN and
password" rather than ldapts' bare
Code: 0x31.An Auth column in the Users list shows where each account signs in from,
naming the actual provider rather than a generic badge.
API changes
Additive only; no existing endpoint changes shape. New:
GET /api/auth/providers(unauthenticated, names and types only),
POST /api/auth/exchange, the redirectendpoints under
/api/auth/{id}/…, admin CRUD under/api/auth-providerswithtest,test-credentialsandsync, andGET /api/access-lists/{id}/verify(unauthenticated by design — it checks a site visitor's credentials, not an
administrator's). All have OpenAPI definitions and
validate-schemapasses.Testing
Verified end to end against real identity providers — OpenLDAP,
mock-oauth2-serverand simpleSAMLphp — not mocks:including revocation, and an account surviving a move between OUs
stateand spentstaterejectedsignature blocks all rejected
entry was deleted
outside the group refused, wrong password, unknown user and empty password all
refused, local entries still work
nginx -t; NPM's nginx is built--with-http_auth_request_module102 backend tests added (vitest). Backend lint and schema validation pass; the
frontend typechecks, lints and builds.
docker/docker-compose.auth-dev.ymlreproduces the whole stack with one command,all three providers pre-wired, credentials in the file header.
Known issues — please read before merging
I reviewed my own work and did not fix everything. These are real and outstanding:
lib/auth/provision.js).An identity is linked to an existing local account on an email match with no
check that the provider verified it, and that happens before the
auto-create gate — so turning off "Create users on first sign in" does not
prevent it. With a provider where users control their own email, setting it to
an administrator's address takes over that account. Needs an
email_verifiedcheck for OIDC and an explicit opt-in for email-based linking.
(
lib/auth/saml.js).validateInResponseTo: "never"with no replay cache;RelayStateis single use but an attacker can mint a fresh one at will.internal/auth.js). The OAuthcallback reflects the IdP-supplied
error_descriptionbefore anystatevalidation. React escapes it so it is not XSS, but it puts arbitrary text in a
security-relevant banner on a trusted origin.
Dependency audit: no advisory in either package traces to a dependency this
branch adds. The 49 pre-existing backend advisories all come from
sqlite3,swagger-parser,proxy-agent,ajv,objection,nodemon,archiver,expressandliquidjs, and want a separate bump pass.Notes for reviewers
ldaptsrather than theldapjsused in Feature/ldap auth #5345: ldapjs was last releasedin December 2023 and its repository is archived, while ldapts covers paging and
binary attributes natively and pulls one transitive dependency.
--experimental-vm-modules.(
dehas 218 keys againsten's 361), so I followed the existing conventionrather than committing machine translations.
both unrelated to this work:
internalUser.createreassignsuserto theinserted auth row so the permissions row gets the wrong id, and
lib/access.js:274constructsPermissionErrorwithoutnew, which makesevery authorization failure surface as 404 instead of 403.
twoFactor.isEnabled()is fixed here, because it threw for any userwithout a local password row and would have rejected every external login.
Type of Change
AI Usage