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 hostname: keep LDAP binds off the public internet +
+
+

+ 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.<% } %> +

+
    +
  • Same Docker/network host (recommended for the proxy or apps on this machine): + use ldaps://sso-manager:636 (the internal service name). + Set conf.ldap.ldapsHost = 'sso-manager'.
  • +
  • LAN host: create an internal DNS record like + 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.
  • +
  • Public hostname: only acceptable behind a VPN or firewall + lockdown — never exposed to the open internet.
  • +
+

+ 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=.... +

+
+
+
+
@@ -428,6 +468,11 @@
+ <% if (ldapsHostExplicit) { %> + + Custom conf.ldap.ldapsHost — override in your secrets file if this name doesn't resolve from the client. + + <% } %>
Base DN
@@ -522,6 +567,8 @@ 'git clone https://github.com/theta42/ldap-client.git', 'cd ldap-client', 'cat > ldap.vars << \'EOF\'', + '# LDAPS host advertised on the Integrations page. If this is an internal-only', + '# hostname, make sure it resolves from this host and the cert SAN matches it.', 'export ldap_host="<%= ldapHost %>"', 'export ldap_base_dn="<%= baseDn %>"', '', diff --git a/secrets.js.example b/secrets.js.example index 9ad7a86..054fabf 100644 --- a/secrets.js.example +++ b/secrets.js.example @@ -30,6 +30,10 @@ module.exports = { bindPassword: 'your-ldap-password', userBase: 'ou=people,dc=example,dc=com', groupBase: 'ou=groups,dc=example,dc=com', + // ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for + // direct LDAPS binds on /integrations. Leave empty to derive from the + // OAuth issuer. Set an internal-only name to avoid port-forwarding 636. + // ldapsPort: 636, }, smtp: { host: 'smtp.example.com',