feat: configurable LDAPS hostname (ldapsHost/ldapsPort) and extensive docs (#89)
Add conf.ldap.ldapsHost / conf.ldap.ldapsPort so the /integrations page can advertise an internal-only LDAPS hostname separate from the public OAuth issuer. This avoids forcing admins to port-forward 636 publicly. - routes/index.js derives LDAPS URL from ldapsHost/ldapsPort with issuer fallback - integrations.ejs adds a contextual help panel explaining TLS hostname validation, the public-issuer default, and recommended internal-DNS / Docker-internal alternatives - conf/base.js, secrets.js.example, DEPLOYMENT.md, docs/configuration.md, and docs/ldap.md document and expose the new options - Add tests/integrations.test.js for default and custom ldapsHost behavior - Bump version to 1.1.17 Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 %>"',
|
||||
'',
|
||||
|
||||
Reference in New Issue
Block a user