This document describes the authentication and authorization model for the internal delivery note browser.
The system uses:
branch, admin, superadmin, dev).The main goals of the authentication and authorization system are:
This document covers:
Non-goals (for this document):
Auth depends on the following environment variables:
SESSION_SECRET (required)
Auth endpoints also require DB connectivity:
MONGODB_URI (required)SESSION_COOKIE_SECURE (optional)
Secure cookie flag.true or false.Default behavior:
Secure cookie is enabled when NODE_ENV === "production".Local HTTP testing (e.g. http://localhost:3000 with Docker + next start):
SESSION_COOKIE_SECURE=false in your local docker env file.Staging/Production:
SESSION_COOKIE_SECURE unset (or true) and run the app behind TLS.If the application is served over plain HTTP (no TLS), many clients will not send
Securecookies back. In that case, logins will appear to “work” (Set-Cookie is present), but subsequent requests will still be unauthenticated.
The repo provides centralized env validation:
lib/config/validateEnv.js validates required env vars and basic sanity checks.scripts/validate-env.mjs runs validation against process.env.In Docker, run validation before starting the server:
node scripts/validate-env.mjs && npm run start
The role enum is:
branch | admin | superadmin | devThis project intentionally separates two independent capabilities:
Branch access (existing capability)
User management (new capability, used by RHL-012)
| Role | Branch access | User management |
|---|---|---|
branch |
Only own branchId |
No |
admin |
All branches | No |
superadmin |
All branches | Yes |
dev |
All branches | Yes |
branchbranchId (e.g. "NL01").adminbranchId = null).superadminbranchId = null).devbranchId = null).admin users keep “access all branches”.dev users keep “manage users”.superadmin is only granted to explicitly designated accounts.Branch access is enforced on filesystem-related endpoints.
Rules:
getSession() returns null).Standard error payload:
{
"error": {
"message": "Human readable message",
"code": "SOME_MACHINE_CODE",
"details": {}
}
}
Common branch RBAC responses:
401 AUTH_UNAUTHENTICATED
{ "error": { "message": "Unauthorized", "code": "AUTH_UNAUTHENTICATED" } }
403 AUTH_FORBIDDEN_BRANCH
{ "error": { "message": "Forbidden", "code": "AUTH_FORBIDDEN_BRANCH" } }
User management is a separate permission capability from branch access.
Rules:
superadmin, devadmin, branchFor endpoints guarded by this capability, the standardized error is:
403 AUTH_FORBIDDEN_USER_MANAGEMENT
{
"error": {
"message": "Forbidden",
"code": "AUTH_FORBIDDEN_USER_MANAGEMENT"
}
}
Authorization rules live in lib/auth/permissions.js:
canAccessBranch(session, branchId)filterBranchesForSession(session, branchIds)canManageUsers(session)requireUserManagement(session)Sessions are implemented as signed JWTs stored in HTTP-only cookies.
{
"userId": "<MongoDB ObjectId as string>",
"role": "branch | admin | superadmin | dev",
"branchId": "NL01 | null",
"email": "name@company.tld | null",
"iat": 1700000000,
"exp": 1700003600
}
Notes:
email is optional and may be null.HS256.SESSION_SECRET.SESSION_MAX_AGE_SECONDS = 8 hours.Cookie name: auth_session
Attributes:
httpOnly: truesecure: resolved via NODE_ENV + optional SESSION_COOKIE_SECURE overridesameSite: "lax"path: "/"maxAge: 8 hoursImplementation lives in lib/auth/session.js:
createSession({ userId, role, branchId, email })getSession()destroySession()All endpoints below are implemented as Next.js App Router Route Handlers in app/api/**/route.js.
POST /api/auth/loginAuthenticate a user and set the session cookie.
Responses:
200 { "ok": true }
400 (invalid JSON/body)
{
"error": {
"message": "Invalid request body",
"code": "VALIDATION_INVALID_JSON"
}
}
400 (missing username/password)
{
"error": {
"message": "Missing username or password",
"code": "VALIDATION_MISSING_FIELD",
"details": { "fields": ["username", "password"] }
}
}
401 (invalid credentials)
{
"error": {
"message": "Invalid credentials",
"code": "AUTH_INVALID_CREDENTIALS"
}
}
500
{
"error": {
"message": "Internal server error",
"code": "INTERNAL_SERVER_ERROR"
}
}
GET /api/auth/logoutClears the session cookie.
200 { "ok": true } on success.GET /api/auth/meReturn the current session identity for frontend consumers.
Response (unauthenticated):
{ "user": null }
Response (authenticated):
{
"user": {
"userId": "...",
"role": "branch|admin|superadmin|dev",
"branchId": "NL01",
"email": "nl01@example.com"
}
}
Notes:
email is optional and may be null.bcrypt hash in users.passwordHash.passwordHash.The password policy is intentionally explicit and testable.
Current policy:
A–Z)0–9)The canonical policy implementation lives in lib/auth/passwordPolicy.js.
POST /api/auth/change-password (RHL-009)Change the password for the currently authenticated user.
Authentication:
401 AUTH_UNAUTHENTICATED.Request body (JSON):
{
"currentPassword": "<string>",
"newPassword": "<string>"
}
Response:
200 { "ok": true }Behavior:
Load the current user by session.userId.
401 AUTH_UNAUTHENTICATED.Verify that currentPassword matches passwordHash.
401 AUTH_INVALID_CREDENTIALS.Validate newPassword against the password policy.
400 VALIDATION_WEAK_PASSWORD with structured details.Hash and persist the new password.
Clear flags / sensitive reset state:
mustChangePassword = falsepasswordResetToken = nullpasswordResetExpiresAt = nullThe user model includes fields that allow implementing password reset flows:
mustChangePasswordpasswordResetTokenpasswordResetExpiresAtHowever, the email/token based recovery flow is intentionally implemented as a separate follow-up ticket/phase, because it introduces additional security surface (token handling and non-leakage) and external SMTP/IT dependencies.
SESSION_SECRET secret and rotate when needed.SESSION_COOKIE_SECURE=false.requireUserManagement(session).