Skip to main content

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-hour maxAge.

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_PASSWORD was left blank (services/bootstrap/createInitialAdmin.js falls 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 — checks req.session.user; redirects to /auth/login?redirect=… if absent.
  • requireAdmin — checks req.session.user?.isAdmin; renders a 403 error page if absent. Always used together with requireAuth (an admin check implies you must already be logged in) on every route under /admin.