Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4fa824609 |
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.1.16",
|
||||
"version": "1.1.17",
|
||||
"author": [
|
||||
{
|
||||
"name": "William Mantly",
|
||||
|
||||
+11
-3
@@ -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,
|
||||
|
||||
@@ -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 <code>conf.ldap.ldapsHost</code>');
|
||||
});
|
||||
});
|
||||
@@ -409,6 +409,46 @@
|
||||
</p>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-warning">
|
||||
<div class="card-header bg-warning bg-opacity-10">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i>
|
||||
LDAPS hostname: keep LDAP binds off the public internet
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small mb-2">
|
||||
LDAPS requires a <strong>hostname</strong>, not a bare IP address, because
|
||||
the TLS client verifies the server name against the certificate.
|
||||
The URL below <% if (ldapsHostExplicit) { %>is set to <code><%= ldapHost %></code> from
|
||||
<code>conf.ldap.ldapsHost</code>.<% } else { %>currently matches the public
|
||||
OAuth issuer host — convenient, but that implies clients reach it through
|
||||
your router on port 636. <strong>Do not port-forward 636 to the internet</strong>
|
||||
for LDAP simple binds; instead pick an internal-only hostname and set
|
||||
<code>conf.ldap.ldapsHost</code>.<% } %>
|
||||
</p>
|
||||
<ul class="small mb-2">
|
||||
<li><strong>Same Docker/network host (recommended for the proxy or apps on this machine):</strong>
|
||||
use <code>ldaps://sso-manager:636</code> (the internal service name).
|
||||
Set <code>conf.ldap.ldapsHost = 'sso-manager'</code>.</li>
|
||||
<li><strong>LAN host:</strong> create an internal DNS record like
|
||||
<code>ldap.internal.example.com</code> → the local IP, get or generate a cert
|
||||
whose SAN matches that name, and set <code>conf.ldap.ldapsHost</code>.
|
||||
A wildcard for <code>*.internal.example.com</code> works well.</li>
|
||||
<li><strong>Public hostname:</strong> only acceptable behind a VPN or firewall
|
||||
lockdown — never exposed to the open internet.</li>
|
||||
</ul>
|
||||
<p class="small mb-0">
|
||||
<b>Trusting the cert:</b> The bundled slapd uses a self-signed cert unless you
|
||||
mount your own at <code>/etc/openldap/certs</code>. Clients must either trust
|
||||
that cert, or set <code>TLS_REQCERT never</code> / <code>rejectUnauthorized: false</code>
|
||||
for LAN-only use. See <a href="/docs/ldap">LDAP docs</a> for the full
|
||||
runbook, including how to set <code>ldapsHost</code> in
|
||||
<code>conf/secrets.js</code> or via <code>app_ldap__ldapsHost=...</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-lg">
|
||||
<div class="card-header shadow">
|
||||
@@ -428,6 +468,11 @@
|
||||
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||
</div>
|
||||
<% if (ldapsHostExplicit) { %>
|
||||
<small class="field-help text-muted d-block">
|
||||
Custom <code>conf.ldap.ldapsHost</code> — override in your secrets file if this name doesn't resolve from the client.
|
||||
</small>
|
||||
<% } %>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Base DN</dt>
|
||||
@@ -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 %>"',
|
||||
'',
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user