diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3615fac..d76d885 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,17 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [Unreleased]
+## [1.1.17] - 2026-07-18
+
+### Added
+- `conf.ldap.ldapsHost` and `conf.ldap.ldapsPort` config options (also settable via `app_ldap__ldapsHost` / `app_ldap__ldapsPort`). When `ldapsHost` is set, the `/integrations` page advertises that hostname for direct LDAPS binds instead of deriving it from the public OAuth issuer. This lets operators use an internal-only hostname (e.g. `ldap.internal.example.com` or `sso-manager` on the Docker network) and avoid port-forwarding 636 to the internet.
+- A contextual help panel on `/integrations` → LDAP explaining why LDAPS needs a hostname (not an IP), why 636 should not be publicly forwarded, and the recommended internal-DNS / Docker-internal alternatives.
+
+### Changed
+- `routes/index.js` now computes the displayed LDAPS URL from `conf.ldap.ldapsHost`/`ldapsPort` with fallback to the OAuth issuer host for backward compatibility.
+- `secrets.js.example`, `docs/configuration.md`, `docs/ldap.md`, and `DEPLOYMENT.md` document the new `ldapsHost`/`ldapsPort` options and recommended network layouts.
+- Bumped version to `1.1.17` in `nodejs/package.json`.
+
## [1.1.16] - 2026-07-18
### Security
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 5532377..ef58cd6 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -188,6 +188,12 @@ valid 10 years, SAN includes the CN + `localhost` + `127.0.0.1`) and listens on
`ldap-certs` volume so it persists across container recreation — clients don't need
to re-trust on every rebuild.
+The `/integrations` page derives its LDAPS URL from the OAuth issuer by default.
+To advertise a separate, internal-only hostname (e.g. `ldap.internal.example.com`
+or `sso-manager` for Docker-internal clients), set `conf.ldap.ldapsHost` in your
+secrets file or pass `app_ldap__ldapsHost=...`. See `docs/ldap.md` for
+recommended network layouts and how to match the cert SAN to the hostname.
+
- **Trusting the self-signed cert** (clients): copy `/etc/openldap/certs/ldap.crt`
out of the container and add it to the client's trusted CA store, or set
`TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with:
diff --git a/docs/configuration.md b/docs/configuration.md
index bcb9b4d..1e6c36c 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -32,6 +32,8 @@ raw strings otherwise.
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
| `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) |
+| `app_ldap__ldapsHost=ldap.internal.example.com` | `conf.ldap.ldapsHost` | string (hostname shown on `/integrations` for LDAPS binds; empty = derive from `oauth.issuer`) |
+| `app_ldap__ldapsPort=636` | `conf.ldap.ldapsPort` | number (port shown on `/integrations`) |
| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string |
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
diff --git a/docs/ldap.md b/docs/ldap.md
index cae91af..dca32be 100644
--- a/docs/ldap.md
+++ b/docs/ldap.md
@@ -118,6 +118,81 @@ volumes:
The entrypoint leaves existing certs untouched (idempotent).
+## Choosing the LDAPS hostname
+
+The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By
+default it derives that URL from the public OAuth issuer (e.g.
+`https://sso.example.com` → `ldaps://sso.example.com:636`). That is convenient,
+but it implies LDAP clients reach your directory through the same public
+hostname — which usually means port-forwarding 636 through your router.
+
+**Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds
+have no rate limiting and are a brute-force target. Instead, use one of these
+internal-only patterns and set `conf.ldap.ldapsHost` (or
+`app_ldap__ldapsHost`) so the `/integrations` page shows the right URL.
+
+### 1. Same Docker / local network host (best for apps on this machine)
+
+If the LDAP client runs on the same Docker network as the SSO Manager (for
+example, the bundled `theta-env` stack), use the internal service name:
+
+```
+ldaps://sso-manager:636
+```
+
+In `conf/secrets.js`:
+
+```javascript
+ldap: {
+ ldapsHost: 'sso-manager',
+ ldapsPort: 636,
+}
+```
+
+The proxy in theta-env already uses this internally. The bundled slapd cert
+includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default,
+so hostname verification works without extra setup.
+
+### 2. LAN host behind your router (best for separate home-lan machines)
+
+Create an internal-only DNS record — e.g. `ldap.internal.example.com` →
+`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get
+or generate a cert whose SAN/CN matches that internal name:
+
+- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the
+ public domain and can complete DNS-01 challenge; the record itself can stay
+ private/routable only inside your LAN.
+- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for
+ `ldap.internal.example.com`, and distribute the CA cert to clients.
+- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy
+ the generated `ldap.crt` to each client and trust it.
+
+In `conf/secrets.js`:
+
+```javascript
+ldap: {
+ ldapsHost: 'ldap.internal.example.com',
+ ldapsPort: 636,
+}
+```
+
+The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`.
+
+### 3. Public hostname (acceptable only behind a VPN/firewall)
+
+If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard,
+etc.) or a tightly locked-down firewall rule. In that case the public hostname
+may be appropriate, but the LDAPS port should still not be reachable from the
+open internet.
+
+### Why not just use the LDAP server's IP address?
+
+TLS clients verify the server name against the certificate. Connecting to
+`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com`
+will fail hostname verification unless you disable cert checks — which removes
+most of the security benefit of LDAPS. Always use a hostname that matches the
+cert.
+
## Service accounts
A service account is a normal `posixAccount` for something that isn't a
diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js
index 17949a9..f76bd3e 100644
--- a/nodejs/conf/base.js
+++ b/nodejs/conf/base.js
@@ -22,6 +22,13 @@ module.exports = {
groupBase: 'ou=groups,dc=example,dc=com',
userFilter: '(objectClass=posixAccount)',
userNameAttribute: 'uid',
+ // Hostname/port advertised on the /integrations page for direct-LDAP
+ // clients. Leave ldapsHost empty to derive it from the OAuth issuer host.
+ // Set it to an internal-only name (e.g. 'ldap.internal.example.com' or
+ // 'sso-manager' on the Docker network) so external clients don't need a
+ // public 636 port forward. See docs/ldap.md.
+ ldapsHost: '',
+ ldapsPort: 636,
// New users/personal groups (see addPosixAccount/addPosixGroup in
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
// Existing entries >= uidGidReservedFloor are ignored when computing
diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json
index 566ca01..5a7181f 100644
--- a/nodejs/package-lock.json
+++ b/nodejs/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
- "version": "1.1.16",
+ "version": "1.1.17",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
- "version": "1.1.16",
+ "version": "1.1.17",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
diff --git a/nodejs/package.json b/nodejs/package.json
index b84b699..5a20593 100755
--- a/nodejs/package.json
+++ b/nodejs/package.json
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
- "version": "1.1.16",
+ "version": "1.1.17",
"author": [
{
"name": "William Mantly",
diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js
index d00ffce..46742f0 100755
--- a/nodejs/routes/index.js
+++ b/nodejs/routes/index.js
@@ -93,7 +93,14 @@ router.get('/login', async function(req, res, next) {
// hardcoded in a doc, so they're always right for *this* deployment.
router.get('/integrations', function(req, res, next) {
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
- const ldapHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
+ // The public-facing host (from the OAuth issuer). Used for OIDC links.
+ const issuerHost = issuer.replace(/^https?:\/\//, '').replace(/:\d+$/, '');
+
+ // The hostname advertised for direct LDAPS binds may be a separate,
+ // internal-only name so admins don't have to port-forward 636 publicly.
+ // Defaults to the issuer host to preserve prior behavior.
+ const ldapsHost = (conf.ldap && conf.ldap.ldapsHost) || issuerHost;
+ const ldapsPort = Number((conf.ldap && conf.ldap.ldapsPort) || 636) || 636;
const userBase = (conf.ldap && conf.ldap.userBase) || 'ou=people,dc=example,dc=com';
const groupBase = (conf.ldap && conf.ldap.groupBase) || 'ou=groups,dc=example,dc=com';
@@ -106,8 +113,9 @@ router.get('/integrations', function(req, res, next) {
...values,
issuer,
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
- ldapHost,
- ldapsUrl: `ldaps://${ldapHost}:636`,
+ ldapHost: ldapsHost,
+ ldapsUrl: `ldaps://${ldapsHost}:${ldapsPort}`,
+ ldapsHostExplicit: !!(conf.ldap && conf.ldap.ldapsHost),
baseDn,
userBase,
groupBase,
diff --git a/nodejs/tests/integrations.test.js b/nodejs/tests/integrations.test.js
new file mode 100644
index 0000000..95ffb9b
--- /dev/null
+++ b/nodejs/tests/integrations.test.js
@@ -0,0 +1,47 @@
+'use strict';
+
+const request = require('supertest');
+const app = require('../app');
+const conf = require('@simpleworkjs/conf');
+
+const ORIG_LDAP = { ...conf.ldap };
+const ORIG_OAUTH = { ...(conf.oauth || {}) };
+
+function restoreConf() {
+ conf.ldap = { ...conf.ldap, ...ORIG_LDAP };
+ conf.oauth = { ...(conf.oauth || {}), ...ORIG_OAUTH };
+}
+
+beforeEach(() => {
+ // Start each test from a known state; the local secrets.js may set an issuer.
+ conf.ldap = { ...conf.ldap, ldapsHost: '', ldapsPort: 636 };
+ if (conf.oauth) conf.oauth.issuer = '';
+});
+
+afterAll(() => {
+ restoreConf();
+});
+
+describe('GET /integrations', () => {
+ test('renders and derives LDAPS URL from the request host by default', async () => {
+ const res = await request(app)
+ .get('/integrations')
+ .set('Host', 'sso.example.com');
+
+ expect(res.status).toBe(200);
+ expect(res.text).toContain('ldaps://sso.example.com:636');
+ });
+
+ test('uses conf.ldap.ldapsHost when set', async () => {
+ conf.ldap = { ...conf.ldap, ldapsHost: 'ldap.internal.example.com', ldapsPort: 1636 };
+
+ const res = await request(app)
+ .get('/integrations')
+ .set('Host', 'public.example.com');
+
+ expect(res.status).toBe(200);
+ expect(res.text).toContain('ldaps://ldap.internal.example.com:1636');
+ expect(res.text).not.toContain('ldaps://public.example.com:636');
+ expect(res.text).toContain('Custom conf.ldap.ldapsHost');
+ });
+});
diff --git a/nodejs/views/integrations.ejs b/nodejs/views/integrations.ejs
index 385b8c8..2c72b99 100644
--- a/nodejs/views/integrations.ejs
+++ b/nodejs/views/integrations.ejs
@@ -409,6 +409,46 @@
+ LDAPS requires a hostname, not a bare IP address, because
+ the TLS client verifies the server name against the certificate.
+ The URL below <% if (ldapsHostExplicit) { %>is set to <%= ldapHost %> from
+ conf.ldap.ldapsHost.<% } else { %>currently matches the public
+ OAuth issuer host — convenient, but that implies clients reach it through
+ your router on port 636. Do not port-forward 636 to the internet
+ for LDAP simple binds; instead pick an internal-only hostname and set
+ conf.ldap.ldapsHost.<% } %>
+
ldaps://sso-manager:636 (the internal service name).
+ Set conf.ldap.ldapsHost = 'sso-manager'.ldap.internal.example.com → the local IP, get or generate a cert
+ whose SAN matches that name, and set conf.ldap.ldapsHost.
+ A wildcard for *.internal.example.com works well.
+ Trusting the cert: The bundled slapd uses a self-signed cert unless you
+ mount your own at /etc/openldap/certs. Clients must either trust
+ that cert, or set TLS_REQCERT never / rejectUnauthorized: false
+ for LAN-only use. See LDAP docs for the full
+ runbook, including how to set ldapsHost in
+ conf/secrets.js or via app_ldap__ldapsHost=....
+
conf.ldap.ldapsHost — override in your secrets file if this name doesn't resolve from the client.
+
+ <% } %>