Files
sso-manager-node/docker-entrypoint.sh
wmantly 4a592f9795 Release 1.11.0: end-user catalog, access requests, nested groups
Closes the end-user half of the directory and adds nested LDAP groups.

The directory could describe the lab but could not tell anyone what they had
or how to reach it, and several of the paths meant to do so were silently
returning nothing:

  - GET /api/discovery/me resolved groups from req.user.groups, which does not
    exist (req.user carries memberOf), so it returned only isPublic resources
    for every human caller -- "My Services" was blank for everyone. The same
    read made isDirectoryAdmin() false for real admins.
  - The portal's "Discover More Services" called the admin-gated endpoint and
    swallowed the 403, so it never rendered for non-admins at all.
  - Services reported no address, because /me had reimplemented getMyAccess
    without its parent-walking resolution.

Adds the catalog at /, self-service access requests, and admin access
visibility (per-resource counts, and the reverse "what can this user reach").

Nested groups come in two halves. groupOfNames.member already accepts a group
DN, so nesting needs no schema -- what it needs is resolution, which no
released OpenLDAP performs. The all-in-one image therefore builds slapd from a
pinned master commit for the nestgroup overlay, and the app computes the
closure itself when pointed at a server without it. Both paths are covered.

member-values is deliberately left out of nestgroup-flags: it expands `member`
when reading a group, which destroys the distinction between "listed here" and
"reachable through a nested group" and is not recoverable afterwards.

Full suite green in both resolution modes: 215 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:22:08 -04:00

481 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
# docker-entrypoint.sh — initialize the bundled OpenLDAP and start SSO Manager.
# Used by Dockerfile.openldap (the all-in-one image: app + slapd in one container).
#
# This is intended for development/testing / single-node deployments. For
# production, run a dedicated LDAP server and point the app at it via app_*
# env vars (or a mounted conf/secrets.js) using the app-only image.
#
# The app reads its configuration from conf/base.js + a secrets file, deep-merged
# by @simpleworkjs/conf (requires >= 1.2.0, pinned in nodejs/package-lock.json),
# with `app_*` environment variables as the highest-precedence override layer.
# This entrypoint exports those `app_*` vars so the app connects to the bundled
# slapd without any mounted secrets file. Any `app_*` var already set in the
# environment wins (the values below are defaults/fallbacks only).
set -e
# ── LDAP server-side configuration ──────────────────────────────────────────
LDAP_BASE_DN="${LDAP_BASE_DN:-dc=example,dc=com}"
LDAP_ADMIN_PASS="${LDAP_ADMIN_PASS:-admin}"
# Derive the DNS domain from the base DN (dc=foo,dc=bar -> foo.bar) unless given.
if [[ -z "${LDAP_DOMAIN:-}" ]]; then
LDAP_DOMAIN=$(echo "$LDAP_BASE_DN" | sed 's/^dc=//; s/,dc=/./g')
fi
ORG_NAME="${ORG_NAME:-SSO Manager}"
LDAP_BIND_DN="${LDAP_BIND_DN:-cn=admin,${LDAP_BASE_DN}}"
# The app (inside the container) talks to the local slapd over localhost.
APP_LDAP_URL="${app_ldap__url:-ldap://localhost:389}"
info() { echo "[INFO] $*"; }
error() { echo "[ERROR] $*" >&2; }
# ── Optional: load operational config from a mounted secrets.js ──────────────
# The unified theta-env stack mounts ./config/sso-secrets.js at /config and
# treats it as the authoritative source for the SSO's config (LDAP base, admin
# password, org name, JWT secret, ...). When present, point CONF_SECRETS at it
# so @simpleworkjs/conf reads it directly (no write access to /app/conf
# needed), and override the env-derived operational vars below with the
# file's values. When absent (standalone / env-var deployments) the env vars
# set above stay in effect and the app_* exports further down are emitted as
# before.
SECRETS_JS_MODE=0
if [[ -f /config/sso-secrets.js ]]; then
export CONF_SECRETS=/config/sso-secrets.js
SECRETS_JS_MODE=1
# Pull the entrypoint's operational vars out of secrets.js in one node call.
# Node emits `KEY<TAB>base64(value)` lines; we decode each with base64 -d and
# assign via printf -v. base64 carries quotes / special chars safely with no
# eval and no shell-quoting gymnastics. No app_* env is exported in this mode
# — @simpleworkjs/conf reads the file directly, and any app_* env would
# override it (precedence: base.js < <env>.js < secrets.js < app_* env).
_node_out="$(node -e '
const c = require("/config/sso-secrets.js");
const b = s => Buffer.from(String(s == null ? "" : s)).toString("base64");
const o = {
LDAP_BASE_DN: (c.stack && c.stack.ldapBaseDn) || "",
LDAP_ADMIN_PASS: (c.ldap && c.ldap.bindPassword) || "",
ORG_NAME: c.name || "",
LDAP_DOMAIN: (c.stack && c.stack.ldapDomain) || "",
LDAP_CERT_CN: (c.stack && c.stack.ldapCertCn) || "",
JWT_SECRET: (c.oauth && c.oauth.jwtSecret) || "",
};
for (const k in o) console.log(k + "\t" + b(o[k]));
')" || { error "Failed to parse /config/sso-secrets.js (see stderr above)"; exit 1; }
[[ -n "$_node_out" ]] || { error "/config/sso-secrets.js produced no config"; exit 1; }
while IFS=$'\t' read -r _k _v; do
[[ -n "$_k" ]] || continue
printf -v "$_k" '%s' "$(printf '%s' "$_v" | base64 -d)"
done <<< "$_node_out"
LDAP_BIND_DN="cn=admin,${LDAP_BASE_DN}"
[[ -n "$LDAP_ADMIN_PASS" ]] || { error "/config/sso-secrets.js: ldap.bindPassword is empty"; exit 1; }
[[ -n "$JWT_SECRET" ]] || { error "/config/sso-secrets.js: oauth.jwtSecret is empty"; exit 1; }
info "Loaded config from /config/sso-secrets.js (secrets.js authoritative)"
fi
# ── Locate the OpenLDAP module directory ────────────────────────────────────
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
# path varies by distro; auto-detect rather than hardcode.
# /opt/openldap/libexec/openldap is first: that is the from-source build (see
# Dockerfile.openldap), which is the only one carrying the nestgroup overlay.
MODULE_PATH=""
for p in /opt/openldap/libexec/openldap /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
done
# Nested-group support is only available when slapd was built with the
# nestgroup overlay. Detect rather than assume, so this entrypoint still
# produces a working slapd.conf against a distro OpenLDAP (where the app falls
# back to resolving nesting itself -- see nodejs/models/group_ldap.js).
NESTGROUP_AVAILABLE=0
if [[ -n "$MODULE_PATH" && -f "$MODULE_PATH/nestgroup.so" ]]; then
NESTGROUP_AVAILABLE=1
fi
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
# network. To keep password binds off the wire in cleartext we expose LDAPS
# (636) and allow StartTLS on 389. By default a self-signed cert is generated on
# first start; mount your own cert+key at LDAP_CERT_DIR to use a CA-signed cert
# instead (idempotent: existing certs are never overwritten).
LDAP_CERT_DIR="${LDAP_CERT_DIR:-/etc/openldap/certs}"
# CN/SAN hostname clients will verify against. Default to the DNS domain derived
# from the base DN (dc=foo,dc=bar -> foo.bar); override for a public hostname.
LDAP_CERT_CN="${LDAP_CERT_CN:-${LDAP_DOMAIN:-localhost}}"
mkdir -p "$LDAP_CERT_DIR"
if [[ -f "$LDAP_CERT_DIR/ldap.crt" && -f "$LDAP_CERT_DIR/ldap.key" ]]; then
info "Using existing LDAP TLS cert at $LDAP_CERT_DIR (mounted or previously generated)"
else
info "Generating self-signed LDAP TLS cert (CN=$LDAP_CERT_CN, valid 10y)..."
openssl req -x509 -newkey rsa:2048 -nodes \
-keyout "$LDAP_CERT_DIR/ldap.key" -out "$LDAP_CERT_DIR/ldap.crt" \
-days 3650 -subj "/CN=$LDAP_CERT_CN" \
-addext "subjectAltName=DNS:$LDAP_CERT_CN,DNS:localhost,IP:127.0.0.1" \
>/dev/null 2>&1 || {
error "Failed to generate LDAP TLS cert"
exit 1
}
fi
# slapd runs as the ldap user and must read both cert and key.
chown -R ldap:ldap "$LDAP_CERT_DIR" 2>/dev/null || true
chmod 600 "$LDAP_CERT_DIR/ldap.key" 2>/dev/null || true
chmod 644 "$LDAP_CERT_DIR/ldap.crt" 2>/dev/null || true
# ── Generate slapd.conf ─────────────────────────────────────────────────────
# We use slapd.conf (static config) rather than cn=config so the whole directory
# is configured from a single generated file. slapd is started with -f below so
# it actually reads this file (the original entrypoint omitted -f, so slapd
# ignored it entirely).
cat > /etc/openldap/slapd.conf << SLAPDEOF
include /etc/openldap/schema/core.schema
include /etc/openldap/schema/cosine.schema
include /etc/openldap/schema/inetorgperson.schema
include /etc/openldap/schema/nis.schema
include /etc/openldap/schema/theta42.schema
include /etc/openldap/schema/sudo.schema
include /etc/openldap/schema/openssh-lpk.schema
SERVER_ID_PLACEHOLDER
# 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+
# the ppolicy schema (pwdPolicy, pwdAccountLockedTime, ...) is built into
# ppolicy.so and registered when the module loads — there is no separate
# ppolicy.schema to include, and loading the module before the overlay below
# is what makes the pwdPolicy objectClass known to slapd.
SLAPMODULEPATH
moduleload back_mdb
moduleload pw-sha2
moduleload ppolicy
moduleload memberof
moduleload refint
moduleload auditlog
NESTGROUP_MODULE_PLACEHOLDER
SYNCPROV_MODULE_PLACEHOLDER
# 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
# case for LDAP bind clients) and treat our own self-signed cert as the CA.
TLSCertificateFile /etc/openldap/certs/ldap.crt
TLSCertificateKeyFile /etc/openldap/certs/ldap.key
TLSCACertificateFile /etc/openldap/certs/ldap.crt
TLSVerifyClient never
# Database configuration
database mdb
# Store the mdb files on the persistent volume (docker-compose mounts a named
# volume at /var/lib/ldap). Without this, mdb defaults to
# /var/lib/openldap/openldap-data and the directory would be lost on recreation.
directory /var/lib/ldap
suffix SUFFIX_PLACEHOLDER
rootdn BIND_DN_PLACEHOLDER
# rootpw uses {SSHA} (built into slapd, no module needed) so slappasswd never
# depends on a loadable module to generate or verify it. User passwords are
# stored as {SSHA512} by the app itself and verified via the pw-sha2 module.
rootpw ROOTPW_PLACEHOLDER
# Indexes
index objectClass eq
index uid eq,sub
index mail eq,sub
index cn eq,sub
index member eq
index uidNumber eq
index gidNumber eq
# ppolicy overlay (account locking — the app's active/inactive toggle relies on
# pwdAccountLockedTime being a known attribute).
overlay ppolicy
ppolicy_default "cn=ppolicy,ou=policies,SUFFIX_PLACEHOLDER"
ppolicy_use_lockout true
# memberof overlay (reverse group membership)
overlay memberof
memberof-group-oc groupOfNames
memberof-member-ad member
memberof-memberof-ad memberOf
# refint overlay (referential integrity on group membership)
overlay refint
refint_attributes memberOf member manager owner
NESTGROUP_OVERLAY_PLACEHOLDER
# auditlog overlay (LDIF audit trail of all changes)
overlay auditlog
auditlog /var/lib/ldap/auditlog.ldif
REPLICATION_BLOCK_PLACEHOLDER
# Access controls
access to attrs=userPassword
by dn="BIND_DN_PLACEHOLDER" write
by anonymous auth
by self write
by * none
access to *
by dn="BIND_DN_PLACEHOLDER" write
by * read
SLAPDEOF
# Generate the rootpw hash ({SSHA}, built-in — no module dependency).
HASHED_PASS=$(slappasswd -s "$LDAP_ADMIN_PASS")
# Fill placeholders.
DC_VALUE="${LDAP_BASE_DN%%,*}" # dc=example
DC_VALUE="${DC_VALUE#dc=}" # example
sed -i "s|SUFFIX_PLACEHOLDER|${LDAP_BASE_DN}|g" /etc/openldap/slapd.conf
sed -i "s|BIND_DN_PLACEHOLDER|${LDAP_BIND_DN}|g" /etc/openldap/slapd.conf
sed -i "s|ROOTPW_PLACEHOLDER|${HASHED_PASS}|g" /etc/openldap/slapd.conf
if [[ -n "$MODULE_PATH" ]]; then
sed -i "s|^SLAPMODULEPATH$|modulepath ${MODULE_PATH}|" /etc/openldap/slapd.conf
else
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
fi
# ── Nested groups (nestgroup overlay) ──
# Three of the four flags, deliberately:
#
# member-filter (member=X) finds parent groups transitively. This is what
# Group.list(dn) rides on -- the core access question.
# memberof-filter (memberOf=X) matches members of nested groups. This is
# what SSSD's ldap_access_filter uses, so SSH/sudo inherit
# nesting without any client-side walking.
# memberof-values expands memberOf when reading a user, so anything that
# reads the attribute rather than searching still sees the
# full picture.
#
# member-values is deliberately NOT enabled. It expands the `member` attribute
# when reading a *group*, which sounds symmetric but destroys the distinction
# between "listed on this group" and "reachable through a nested one" -- and
# that distinction is not recoverable afterwards, because the raw values are
# simply not returned. The Groups UI needs it to show nested groups as nested
# rather than as a crowd of phantom users, and un-nesting needs it to know what
# it is actually removing. Transitive *answers* come from the filter flags and
# from Group.effectiveMembers(), which computes the closure explicitly.
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
info "nestgroup overlay available — nested groups resolved server-side"
sed -i "s|^NESTGROUP_MODULE_PLACEHOLDER$|moduleload nestgroup|" /etc/openldap/slapd.conf
NESTGROUP_BLOCK="# nestgroup overlay (server-side nested group evaluation)\noverlay nestgroup\nnestgroup-base ou=groups,${LDAP_BASE_DN}\nnestgroup-flags member-filter memberof-filter memberof-values"
sed -i "s|^NESTGROUP_OVERLAY_PLACEHOLDER$|${NESTGROUP_BLOCK}|" /etc/openldap/slapd.conf
else
info "nestgroup overlay not present in ${MODULE_PATH:-<no module path>} — nested groups will be resolved by the app instead"
sed -i "/^NESTGROUP_MODULE_PLACEHOLDER$/d" /etc/openldap/slapd.conf
sed -i "/^NESTGROUP_OVERLAY_PLACEHOLDER$/d" /etc/openldap/slapd.conf
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 -R ldap:ldap /var/lib/ldap 2>/dev/null || true
# ── Start slapd ─────────────────────────────────────────────────────────────
info "Starting OpenLDAP (base DN: ${LDAP_BASE_DN})..."
# -f forces slapd to use our generated slapd.conf (not cn=config).
# -h listens on ldap:/// (389: plain + StartTLS) and ldaps:/// (636: LDAPS).
# ldapi:/// is intentionally omitted: its default socket dir doesn't exist on
# Alpine and the container only uses simple bind over ldap://localhost:389.
slapd -d 256 -u ldap -g ldap -f /etc/openldap/slapd.conf -h "ldap:/// ldaps:///" >> /var/lib/ldap/slapd.log 2>&1 &
SLAPD_PID=$!
# Wait for slapd to answer the root DSE (means it's up, regardless of DB state).
for i in $(seq 1 30); do
if ldapsearch -x -H ldap://localhost:389 -b "" -s base "(objectClass=*)" >/dev/null 2>&1; then
info "OpenLDAP is ready"
break
fi
info "Waiting for OpenLDAP... ($i/30)"
sleep 1
done
if ! kill -0 "$SLAPD_PID" 2>/dev/null; then
error "OpenLDAP failed to start"
exit 1
fi
# ── Initialize the directory tree (idempotent) ──────────────────────────────
# Only seed if the base DN doesn't exist yet, so restarting the container is safe.
if ! ldapsearch -x -H ldap://localhost:389 -b "$LDAP_BASE_DN" -s base "(objectClass=*)" >/dev/null 2>&1; then
info "Initializing LDAP directory..."
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF
dn: ${LDAP_BASE_DN}
objectClass: dcObject
objectClass: organization
dc: ${DC_VALUE}
o: ${ORG_NAME}
dn: ou=people,${LDAP_BASE_DN}
objectClass: organizationalUnit
ou: people
dn: ou=groups,${LDAP_BASE_DN}
objectClass: organizationalUnit
ou: groups
dn: ou=policies,${LDAP_BASE_DN}
objectClass: organizationalUnit
ou: policies
dn: cn=ppolicy,ou=policies,${LDAP_BASE_DN}
objectClass: top
objectClass: organizationalRole
objectClass: pwdPolicy
cn: ppolicy
pwdAttribute: 2.5.4.35
pwdLockout: TRUE
pwdMustChange: FALSE
pwdAllowUserChange: TRUE
EOF
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
# app_sso_service_account is a marker (not a permission gate) for
# non-person accounts -- see the Users page.
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
objectClass: groupOfNames
objectClass: top
cn: ${group}
description: ${ORG_NAME} ${group} group
member: ${LDAP_BIND_DN}
EOF
done
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
# hold those rights by membership rather than by a special case in app code.
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
# anything binding LDAP directly -- instead of only to callers that happen
# to route through utils/permission.js.
#
# app_sso_service_account is deliberately excluded: it is a marker for
# non-person accounts, not a permission, and nesting admins into it would
# misclassify them as service accounts on the Users page.
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
changetype: modify
add: member
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
EOF
done
info "Nested app_super_admin into the SSO admin groups"
fi
info "LDAP directory initialized"
else
info "LDAP directory already initialized — skipping seed"
fi
# ── Start Redis ──────────────────────────────────────────────────────────────
# The app stores models/sessions in Redis (model-redis), and the SSO's
# non-bootstrap OAuth clients also live there. The all-in-one image bundles a
# Redis server for a self-contained single-node deployment. Persist it to /data
# (AOF + RDB) so OAuth clients, tokens, and other Redis-backed state survive
# container recreation. Point the app at an external Redis instead by setting
# app_redis__host before starting (then no bundled Redis runs here). redis runs
# as root in this image, so a root-owned /data is writable.
if [[ -z "${app_redis__host:-}" ]]; then
REDIS_DATA_DIR="${REDIS_DATA_DIR:-/data}"
mkdir -p "$REDIS_DATA_DIR"
chmod 700 "$REDIS_DATA_DIR"
info "Starting Redis (AOF persisted to $REDIS_DATA_DIR)..."
redis-server --daemonize no --dir "$REDIS_DATA_DIR" --appendonly yes \
--appendfilename appendonly.aof --save 900 1 --save 300 10 --save 60 10000 \
--dbfilename dump.rdb &
REDIS_PID=$!
for i in $(seq 1 15); do
if redis-cli ping >/dev/null 2>&1; then
info "Redis is ready"
break
fi
sleep 0.5
done
if ! kill -0 "$REDIS_PID" 2>/dev/null; then
error "Redis failed to start"
exit 1
fi
# App defaults to 127.0.0.1:6379 (the redis client default), so no override
# is needed when running the bundled Redis.
fi
# ── Generate a JWT secret if none was provided (env mode only) ──────────────
# In secrets.js mode the JWT secret comes from the file and was validated above.
if [[ "${SECRETS_JS_MODE:-0}" != 1 && -z "${JWT_SECRET:-}" && -z "${app_oauth__jwtSecret:-}" ]]; then
if command -v openssl >/dev/null 2>&1; then
JWT_SECRET=$(openssl rand -hex 32)
else
JWT_SECRET="$LDAP_ADMIN_PASS-jwt-secret-$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' ')"
fi
info "Generated JWT secret (set JWT_SECRET or app_oauth__jwtSecret to persist)"
fi
# ── Export app_* config overrides for the SSO Manager process (env mode) ─────
# In secrets.js mode the app reads /app/conf/secrets.js directly, so we export
# NO app_* vars — they would override the file (@simpleworkjs/conf precedence:
# base.js < <env>.js < secrets.js < app_* env). In env mode these remain the
# highest-precedence layer, derived from the LDAP_* / ORG_NAME / SMTP_* env.
if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
export app_ldap__url="${app_ldap__url:-$APP_LDAP_URL}"
export app_ldap__bindDN="${app_ldap__bindDN:-$LDAP_BIND_DN}"
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
# Tell the app whether slapd resolves nested groups for it. When true the app
# trusts a plain (member=) search to be transitive; when false it computes
# the closure itself. Getting this wrong in the "true" direction silently
# under-grants, so it is derived from the same nestgroup.so probe that
# decides whether the overlay is configured at all -- never hardcoded.
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
export app_ldap__nestedGroupsServerSide="${app_ldap__nestedGroupsServerSide:-true}"
fi
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
# public https URL on the SSO subdomain of the LDAP domain; override with
# OAUTH_ISSUER / app_oauth__issuer. Computed here (not in compose) because
# compose v1 doesn't interpolate nested ${VAR:-...} defaults.
export app_oauth__issuer="${app_oauth__issuer:-https://sso.${LDAP_DOMAIN}}"
export app_name="${app_name:-$ORG_NAME}"
# SMTP (optional). If no user/pass, disable auth by clearing the user.
export app_smtp__host="${app_smtp__host:-${SMTP_HOST:-localhost}}"
export app_smtp__port="${app_smtp__port:-${SMTP_PORT:-587}}"
if [[ -n "${SMTP_USER:-}" || -n "${SMTP_PASS:-}" ]]; then
export app_smtp__user="${app_smtp__user:-$SMTP_USER}"
export app_smtp__pass="${app_smtp__pass:-$SMTP_PASS}"
else
export app_smtp__user="${app_smtp__user:-}"
fi
fi
# HTTP port for the app (bin/www reads NODE_PORT).
export NODE_PORT="${NODE_PORT:-${PORT:-3001}}"
export NODE_ENV="${NODE_ENV:-production}"
info "Starting SSO Manager on port ${NODE_PORT}..."
exec "$@"