Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ce36b4a14 | |||
| cda76d3889 | |||
| 2c11226793 | |||
| 80d88b083c | |||
| b4fa824609 |
@@ -6,6 +6,17 @@ correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.1.16] - 2026-07-18
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ docker compose exec sso-manager ldapsearch -x -H ldap://localhost:389 \
|
|||||||
| `PORT` | `3001` | host port mapped to the UI |
|
| `PORT` | `3001` | host port mapped to the UI |
|
||||||
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
|
| `LDAPS_PORT` | `636` | host port mapped to LDAPS |
|
||||||
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
|
| `LDAP_PORT` | `389` | uncomment the host mapping in compose to expose plain LDAP (not recommended) |
|
||||||
|
| `LDAP_SERVER_ID` | empty | Unique integer ID (e.g. 1, 2) required to enable Multi-Master replication |
|
||||||
|
| `LDAP_REPLICATION_HOSTS` | empty | Space-separated list of other sites' LDAP URLs for replication (e.g. `ldaps://site2:636`) |
|
||||||
|
|
||||||
Any `app_*` var may also be set directly to override any config value (see the
|
Any `app_*` var may also be set directly to override any config value (see the
|
||||||
table at the top).
|
table at the top).
|
||||||
@@ -188,6 +190,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
|
`ldap-certs` volume so it persists across container recreation — clients don't need
|
||||||
to re-trust on every rebuild.
|
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`
|
- **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
|
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:
|
`TLS_REQCERT never` for quick-and-dirty LAN use. Fetch it with:
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ RUN apk add --no-cache \
|
|||||||
openldap-overlay-ppolicy \
|
openldap-overlay-ppolicy \
|
||||||
openldap-overlay-memberof \
|
openldap-overlay-memberof \
|
||||||
openldap-overlay-refint \
|
openldap-overlay-refint \
|
||||||
|
openldap-overlay-syncprov \
|
||||||
openldap-passwd-sha2 \
|
openldap-passwd-sha2 \
|
||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
bash \
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ phone-home, no hosted control plane, and no per-user pricing.
|
|||||||
drive the management API from scripts or CI, scoped to their own permissions.
|
drive the management API from scripts or CI, scoped to their own permissions.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
||||||
the pieces separately against your own LDAP/Redis via `app_*` env config.
|
the pieces separately against your own LDAP/Redis via `app_*` env config.
|
||||||
|
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
|
||||||
|
|
||||||
## Why this over the alternatives
|
## Why this over the alternatives
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
site1:
|
||||||
|
build: .
|
||||||
|
container_name: sso_site1
|
||||||
|
environment:
|
||||||
|
- LDAP_SERVER_ID=1
|
||||||
|
- LDAP_REPLICATION_HOSTS=ldap://site2:389
|
||||||
|
- LDAP_BASE_DN=dc=test,dc=local
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
- "10389:389"
|
||||||
|
volumes:
|
||||||
|
- site1-ldap:/var/lib/ldap
|
||||||
|
- site1-redis:/data
|
||||||
|
|
||||||
|
site2:
|
||||||
|
build: .
|
||||||
|
container_name: sso_site2
|
||||||
|
environment:
|
||||||
|
- LDAP_SERVER_ID=2
|
||||||
|
- LDAP_REPLICATION_HOSTS=ldap://site1:389
|
||||||
|
- LDAP_BASE_DN=dc=test,dc=local
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
ports:
|
||||||
|
- "3002:3001"
|
||||||
|
- "20389:389"
|
||||||
|
volumes:
|
||||||
|
- site2-ldap:/var/lib/ldap
|
||||||
|
- site2-redis:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
site1-ldap:
|
||||||
|
site1-redis:
|
||||||
|
site2-ldap:
|
||||||
|
site2-redis:
|
||||||
@@ -125,6 +125,8 @@ include /etc/openldap/schema/theta42.schema
|
|||||||
include /etc/openldap/schema/sudo.schema
|
include /etc/openldap/schema/sudo.schema
|
||||||
include /etc/openldap/schema/openssh-lpk.schema
|
include /etc/openldap/schema/openssh-lpk.schema
|
||||||
|
|
||||||
|
SERVER_ID_PLACEHOLDER
|
||||||
|
|
||||||
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
|
# Module loading (pw-sha2 provides {SSHA512} used by the app for user passwords;
|
||||||
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
|
# ppolicy/memberof/refint are the overlays the app depends on). On OpenLDAP 2.5+
|
||||||
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
|
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
|
||||||
@@ -137,6 +139,7 @@ moduleload pw-sha2
|
|||||||
moduleload ppolicy
|
moduleload ppolicy
|
||||||
moduleload memberof
|
moduleload memberof
|
||||||
moduleload refint
|
moduleload refint
|
||||||
|
SYNCPROV_MODULE_PLACEHOLDER
|
||||||
|
|
||||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
||||||
# generated/mounted above. We accept clients without their own cert (the common
|
# generated/mounted above. We accept clients without their own cert (the common
|
||||||
@@ -184,6 +187,8 @@ memberof-memberof-ad memberOf
|
|||||||
overlay refint
|
overlay refint
|
||||||
refint_attributes memberOf member manager owner
|
refint_attributes memberOf member manager owner
|
||||||
|
|
||||||
|
REPLICATION_BLOCK_PLACEHOLDER
|
||||||
|
|
||||||
# Access controls
|
# Access controls
|
||||||
access to attrs=userPassword
|
access to attrs=userPassword
|
||||||
by dn="BIND_DN_PLACEHOLDER" write
|
by dn="BIND_DN_PLACEHOLDER" write
|
||||||
@@ -211,6 +216,30 @@ else
|
|||||||
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Multi-Master Replication Configuration ──
|
||||||
|
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
||||||
|
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
||||||
|
sed -i "s|^SERVER_ID_PLACEHOLDER|ServerID ${LDAP_SERVER_ID}|" /etc/openldap/slapd.conf
|
||||||
|
sed -i "s|^SYNCPROV_MODULE_PLACEHOLDER|moduleload syncprov|" /etc/openldap/slapd.conf
|
||||||
|
|
||||||
|
# Generate syncrepl blocks
|
||||||
|
REPL_BLOCK="overlay syncprov\nsyncprov-checkpoint 100 10\nsyncprov-sessionlog 100\n\n"
|
||||||
|
RID=100
|
||||||
|
for HOST in ${LDAP_REPLICATION_HOSTS}; do
|
||||||
|
RID=$((RID + 1))
|
||||||
|
REPL_BLOCK="${REPL_BLOCK}syncrepl rid=${RID}\n provider=${HOST}\n type=refreshAndPersist\n retry=\"60 +\"\n searchbase=\"${LDAP_BASE_DN}\"\n bindmethod=simple\n binddn=\"${LDAP_BIND_DN}\"\n credentials=\"${LDAP_ADMIN_PASS}\"\n\n"
|
||||||
|
done
|
||||||
|
REPL_BLOCK="${REPL_BLOCK}mirrormode on\n"
|
||||||
|
|
||||||
|
# Replace placeholder (awk is safer for multiline replacements than sed)
|
||||||
|
awk -v repl="$(printf '%b' "$REPL_BLOCK")" '{gsub(/REPLICATION_BLOCK_PLACEHOLDER/, repl)}1' /etc/openldap/slapd.conf > /etc/openldap/slapd.conf.tmp
|
||||||
|
mv /etc/openldap/slapd.conf.tmp /etc/openldap/slapd.conf
|
||||||
|
else
|
||||||
|
sed -i "/^SERVER_ID_PLACEHOLDER/d" /etc/openldap/slapd.conf
|
||||||
|
sed -i "/^SYNCPROV_MODULE_PLACEHOLDER/d" /etc/openldap/slapd.conf
|
||||||
|
sed -i "/^REPLICATION_BLOCK_PLACEHOLDER/d" /etc/openldap/slapd.conf
|
||||||
|
fi
|
||||||
|
|
||||||
chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
|
chown ldap:ldap /etc/openldap/slapd.conf 2>/dev/null || true
|
||||||
chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true
|
chown -R ldap:ldap /var/lib/ldap 2>/dev/null || true
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ raw strings otherwise.
|
|||||||
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
|
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
|
||||||
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
|
| `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__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__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
|
||||||
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | 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 |
|
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ backend, that's the niche.
|
|||||||
Emby, …) uses LDAPS/StartTLS against the same directory.
|
Emby, …) uses LDAPS/StartTLS against the same directory.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||||
run the pieces separately via `app_*` env config.
|
run the pieces separately via `app_*` env config.
|
||||||
|
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,81 @@ volumes:
|
|||||||
|
|
||||||
The entrypoint leaves existing certs untouched (idempotent).
|
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
|
## Service accounts
|
||||||
|
|
||||||
A service account is a normal `posixAccount` for something that isn't a
|
A service account is a normal `posixAccount` for something that isn't a
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Geo-Location Scaling (Replication)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Geo-Location Scaling (Replication)
|
||||||
|
|
||||||
|
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
|
||||||
|
|
||||||
|
## Why and when to use this?
|
||||||
|
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
|
||||||
|
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
|
||||||
|
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
|
||||||
|
|
||||||
|
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
|
||||||
|
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
|
||||||
|
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
|
||||||
|
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
To enable replication, you must pass two environment variables to the `sso-manager` container:
|
||||||
|
|
||||||
|
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
|
||||||
|
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
|
||||||
|
|
||||||
|
### Example using `theta-env` / Docker Compose
|
||||||
|
|
||||||
|
**Site 1 (`setup.env` or `docker-compose.yml`)**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=1
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 2 (`setup.env` or `docker-compose.yml`)**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=2
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Site 3 (`setup.env` or `docker-compose.yml`)**
|
||||||
|
```env
|
||||||
|
LDAP_SERVER_ID=3
|
||||||
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
||||||
|
```
|
||||||
|
|
||||||
|
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
|
||||||
|
|
||||||
|
## User Locations
|
||||||
|
|
||||||
|
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
|
||||||
@@ -22,6 +22,13 @@ module.exports = {
|
|||||||
groupBase: 'ou=groups,dc=example,dc=com',
|
groupBase: 'ou=groups,dc=example,dc=com',
|
||||||
userFilter: '(objectClass=posixAccount)',
|
userFilter: '(objectClass=posixAccount)',
|
||||||
userNameAttribute: 'uid',
|
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
|
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
||||||
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
||||||
// Existing entries >= uidGidReservedFloor are ignored when computing
|
// Existing entries >= uidGidReservedFloor are ignored when computing
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ async function addPosixAccount(client, data){
|
|||||||
entry.dateOfBirth = data.dob;
|
entry.dateOfBirth = data.dob;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.location) {
|
||||||
|
entry.l = data.location;
|
||||||
|
}
|
||||||
|
|
||||||
// userPassword is optional -- a service account with no password set
|
// userPassword is optional -- a service account with no password set
|
||||||
// simply can't bind (no special enforcement needed, that's the default
|
// simply can't bind (no special enforcement needed, that's the default
|
||||||
// LDAP simple-bind behavior for an entry lacking the attribute).
|
// LDAP simple-bind behavior for an entry lacking the attribute).
|
||||||
@@ -221,6 +225,7 @@ const user_parse = function(data){
|
|||||||
data.username = data[conf.userNameAttribute]
|
data.username = data[conf.userNameAttribute]
|
||||||
data.userPassword = undefined;
|
data.userPassword = undefined;
|
||||||
}
|
}
|
||||||
|
data.location = data.l ? String(data.l) : '';
|
||||||
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
|
// Use truthy strings so jq-repeat section blocks ({{#isActive}}) fire correctly
|
||||||
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
|
data.isActive = data.pwdAccountLockedTime ? '' : 'active';
|
||||||
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
|
data.isInactive = data.pwdAccountLockedTime ? 'inactive' : '';
|
||||||
@@ -519,6 +524,16 @@ User.update = async function(data){
|
|||||||
this.dateOfBirth = data.dateOfBirth;
|
this.dateOfBirth = data.dateOfBirth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(data.location !== undefined){
|
||||||
|
await client.modify(this.dn, [
|
||||||
|
new Change({
|
||||||
|
operation: 'replace',
|
||||||
|
modification: new Attribute({ type: 'l', values: [data.location] }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
this.location = data.location;
|
||||||
|
}
|
||||||
|
|
||||||
if(data.manager !== undefined){
|
if(data.manager !== undefined){
|
||||||
// Client sends uids; resolve each to a DN before writing --
|
// Client sends uids; resolve each to a DN before writing --
|
||||||
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
|
// manager (COSINE, SUP distinguishedName) stores DNs, not uids.
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.16",
|
"version": "1.1.17",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.16",
|
"version": "1.1.17",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.1.16",
|
"version": "1.1.17",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"name": "William Mantly",
|
"name": "William Mantly",
|
||||||
|
|||||||
+50
-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.
|
// hardcoded in a doc, so they're always right for *this* deployment.
|
||||||
router.get('/integrations', function(req, res, next) {
|
router.get('/integrations', function(req, res, next) {
|
||||||
const issuer = ((conf.oauth && conf.oauth.issuer) || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
|
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 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';
|
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,
|
...values,
|
||||||
issuer,
|
issuer,
|
||||||
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
discoveryUrl: `${issuer}/.well-known/openid-configuration`,
|
||||||
ldapHost,
|
ldapHost: ldapsHost,
|
||||||
ldapsUrl: `ldaps://${ldapHost}:636`,
|
ldapsUrl: `ldaps://${ldapsHost}:${ldapsPort}`,
|
||||||
|
ldapsHostExplicit: !!(conf.ldap && conf.ldap.ldapsHost),
|
||||||
baseDn,
|
baseDn,
|
||||||
userBase,
|
userBase,
|
||||||
groupBase,
|
groupBase,
|
||||||
@@ -137,6 +145,45 @@ router.get('/token', function(req, res, next) {
|
|||||||
res.render('token', {...values});
|
res.render('token', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/sites', async function(req, res, next) {
|
||||||
|
const net = require('net');
|
||||||
|
const url = require('url');
|
||||||
|
|
||||||
|
const myId = process.env.LDAP_SERVER_ID || 'Standalone';
|
||||||
|
const hostsStr = process.env.LDAP_REPLICATION_HOSTS || '';
|
||||||
|
const hosts = hostsStr.split(' ').filter(h => h);
|
||||||
|
|
||||||
|
const sites = await Promise.all(hosts.map(hostUrl => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
const u = new url.URL(hostUrl);
|
||||||
|
const port = u.port || (u.protocol === 'ldaps:' ? 636 : 389);
|
||||||
|
const hostname = u.hostname;
|
||||||
|
const socket = new net.Socket();
|
||||||
|
socket.setTimeout(2000);
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ url: hostUrl, status: 'Online' });
|
||||||
|
});
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ url: hostUrl, status: 'Offline (Timeout)' });
|
||||||
|
});
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve({ url: hostUrl, status: 'Offline (' + err.code + ')' });
|
||||||
|
});
|
||||||
|
socket.connect(port, hostname);
|
||||||
|
} catch (e) {
|
||||||
|
resolve({ url: hostUrl, status: 'Invalid URL' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.render('sites', { ...values, myId, sites });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
router.get('/login/resetpassword/:token', async function(req, res, next){
|
router.get('/login/resetpassword/:token', async function(req, res, next){
|
||||||
let token = await PasswordResetToken.get(req.params.token);
|
let token = await PasswordResetToken.get(req.params.token);
|
||||||
|
|||||||
@@ -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>
|
</p>
|
||||||
|
|
||||||
<div class="row g-3">
|
<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="col-lg-6">
|
||||||
<div class="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
<div class="card-header shadow">
|
<div class="card-header shadow">
|
||||||
@@ -428,6 +468,11 @@
|
|||||||
<input type="text" id="f-ldapsUrl" class="form-control font-monospace" readonly value="<%= ldapsUrl %>">
|
<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>
|
<button class="btn btn-outline-secondary" type="button" onclick="copyField('f-ldapsUrl', this)" title="Copy"><i class="fa-solid fa-copy"></i></button>
|
||||||
</div>
|
</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>
|
</dd>
|
||||||
|
|
||||||
<dt class="col-sm-4">Base DN</dt>
|
<dt class="col-sm-4">Base DN</dt>
|
||||||
@@ -522,6 +567,8 @@
|
|||||||
'git clone https://github.com/theta42/ldap-client.git',
|
'git clone https://github.com/theta42/ldap-client.git',
|
||||||
'cd ldap-client',
|
'cd ldap-client',
|
||||||
'cat > ldap.vars << \'EOF\'',
|
'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_host="<%= ldapHost %>"',
|
||||||
'export ldap_base_dn="<%= baseDn %>"',
|
'export ldap_base_dn="<%= baseDn %>"',
|
||||||
'',
|
'',
|
||||||
|
|||||||
@@ -240,6 +240,7 @@
|
|||||||
<i>Phone:</i> <b>{{mobile}}</b>
|
<i>Phone:</i> <b>{{mobile}}</b>
|
||||||
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
|
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
|
||||||
<br />
|
<br />
|
||||||
|
<i>Location (Site):</i> <b>{{location}} </b><br />
|
||||||
<i>LDAP DN:</i> <b>{{dn}} </b><br />
|
<i>LDAP DN:</i> <b>{{dn}} </b><br />
|
||||||
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
|
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
|
||||||
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
|
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
|
||||||
@@ -323,6 +324,10 @@
|
|||||||
<label class="form-label">Mobile Phone</label>
|
<label class="form-label">Mobile Phone</label>
|
||||||
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
|
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Location (Site)</label>
|
||||||
|
<input type="text" class="form-control" name="location" placeholder="Site One" value="{{location}}" />
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Home Directory</label>
|
<label class="form-label">Home Directory</label>
|
||||||
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
|
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<%- include('header') %>
|
||||||
|
|
||||||
|
<div class="container-fluid" style="margin-top: 80px;">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 offset-md-2">
|
||||||
|
<div class="card shadow-lg mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fa-solid fa-network-wired"></i> Sites & Replication
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="mb-4">
|
||||||
|
This page shows the status of Multi-Master LDAP replication peers.
|
||||||
|
<br/>Your Server ID: <strong><%= myId %></strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table class="table table-striped table-bordered">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Site LDAP URL</th>
|
||||||
|
<th>Replication Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% if (sites.length === 0) { %>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="text-center text-muted">No replication peers configured in environment (LDAP_REPLICATION_HOSTS is empty).</td>
|
||||||
|
</tr>
|
||||||
|
<% } else { %>
|
||||||
|
<% sites.forEach(function(site) { %>
|
||||||
|
<tr>
|
||||||
|
<td class="align-middle"><strong><%= site.url %></strong></td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<% if (site.status === 'Online') { %>
|
||||||
|
<span class="badge bg-success"><i class="fa-solid fa-circle-check"></i> Online</span>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="badge bg-danger"><i class="fa-solid fa-circle-xmark"></i> <%= site.status %></span>
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('footer') %>
|
||||||
@@ -56,6 +56,12 @@
|
|||||||
Invites
|
Invites
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item group-required group-required-app_sso_admin">
|
||||||
|
<a class="nav-link" href="/sites">
|
||||||
|
<i class="fa-solid fa-network-wired"></i>
|
||||||
|
Sites
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item group-required group-required-app_sso_admin">
|
<li class="nav-item group-required group-required-app_sso_admin">
|
||||||
<a class="nav-link" href="/dashboard">
|
<a class="nav-link" href="/dashboard">
|
||||||
<i class="fa-solid fa-gauge-high"></i>
|
<i class="fa-solid fa-gauge-high"></i>
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ async function fetchUsernameSuggestions() {
|
|||||||
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
|
<input type="text" class="form-control shadow" name="mobile" placeholder="+14155551234" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Location (Site) <small class="text-muted">(optional)</small></label>
|
||||||
|
<input type="text" class="form-control shadow" name="location" placeholder="Site One" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Password</label>
|
<label class="form-label">Password</label>
|
||||||
<input type="password" class="form-control shadow" name="userPassword" placeholder="Atleast 5 char. long" validate="password:5"/>
|
<input type="password" class="form-control shadow" name="userPassword" placeholder="Atleast 5 char. long" validate="password:5"/>
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ module.exports = {
|
|||||||
bindPassword: 'your-ldap-password',
|
bindPassword: 'your-ldap-password',
|
||||||
userBase: 'ou=people,dc=example,dc=com',
|
userBase: 'ou=people,dc=example,dc=com',
|
||||||
groupBase: 'ou=groups,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: {
|
smtp: {
|
||||||
host: 'smtp.example.com',
|
host: 'smtp.example.com',
|
||||||
|
|||||||
Reference in New Issue
Block a user