Auth & Sessions
How login, sessions, and the forced-password-change flow work. Only two auth mechanisms exist in this codebase — there's no OIDC/SAML/SSO support anywhere.
Local accounts
authController.js#loginLocal looks up the User by lowercase username, explicitly requesting the
normally-hidden passwordHash field (select:false by default on the schema — see
Data Model), then compares it with bcrypt.compare. Every place a
password is set — account creation, self-service change, admin reset, CSV import — hashes it with
bcrypt.hash(password, 12).
LDAP / Active Directory
Only active when AUTH_MODE=ldap. config/ldap.js builds a passport-ldapauth LdapStrategy from
environment variables (ldapURL, ldapBindDN, ldapBindPW, ldapSearchBase, ldapSearchFilter,
the last expecting a {{username}} placeholder), registered only at boot when LDAP mode is enabled.
authController.js#loginLdap runs passport.authenticate('ldapauth', { session: false }, …) —
session:false because Passport's own session support isn't used at all; express-session is the
actual mechanism (see below). On a successful bind, it pulls sAMAccountName or
uid as the username and cn or displayName as the display name from the directory response, then
calls services/database/users/getOrCreateUser.js to upsert a local shadow User record — no
passwordHash is ever stored for an LDAP-backed account. Every subsequent login re-authenticates
against the directory and just refreshes displayName/lastLogin on that shadow record.
Because there's a real User document either way, everything downstream (sessions, admin flags,
theme preferences, ownership of plans) works identically regardless of which auth mode created it.
Establishing a session
Both paths funnel into authController.js#establishSession, which writes:
req.session.user = { id, username, displayName, isAdmin, themeColors, mustChangePassword }
and calls req.session.save(). Every view and controller reads the logged-in user from
req.session.user (surfaced to views as res.locals.user by the locals-injector middleware in
server.js) rather than re-querying the database on every request.
Session storage
middleware/session.js configures express-session with:
secret:process.env.sessionSecret— baked into the Docker image at build time and persisted via a volume (see Architecture → Deployment notes).resave: false,saveUninitialized: false.- Cookie:
httpOnly: true,secure: false, 4-hourmaxAge.
No external session store is configured — sessions live in the default in-memory MemoryStore.
Two practical consequences worth knowing:
- Restarting the app process logs everyone out.
- Sessions aren't shared across multiple instances — this app isn't designed to be horizontally scaled behind a load balancer as-is.
For a single self-hosted container (the only supported deployment shape today), neither is an issue in practice.
Forced password change
User.mustChangePassword is set to true whenever someone other than the account holder set the
password:
- The bootstrap default admin, only when
ADMIN_PASSWORDwas left blank (services/bootstrap/createInitialAdmin.jsfalls back to the literal default"admin"in that case). - A brand-new user created by an admin (
adminController.js#createUser). - An admin resetting someone else's password (
adminController.js#resetPassword) — resetting your own password as an admin does not set the flag.
middleware/forcePasswordChange.js checks this flag on every request (mounted after the license gate,
before the routers) and blocks everything except /auth/change-password and /auth/logout — a JSON
403 for /api/* paths, a redirect otherwise — until authController.js#changePassword clears it. The
change-password flow requires no old-password confirmation, just a new password (minimum 8 characters)
matching a confirmation field, and it clears the flag in both the database and the live session
immediately so the very next request is unblocked.
LDAP accounts never have this flag set — there's no local password to force a change on.
Route-level guards
middleware/auth.js exports two guards applied per-route in routes/*.js:
requireAuth— checksreq.session.user; redirects to/auth/login?redirect=…if absent.requireAdmin— checksreq.session.user?.isAdmin; renders a 403 error page if absent. Always used together withrequireAuth(an admin check implies you must already be logged in) on every route under/admin.