This document describes the authentication and authorization model for the internal delivery note browser.
The system uses:
branch, admin, 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
branchbranchId (e.g. "NL01").adminbranchId = null).devbranchId = null).RBAC is enforced on branch-related filesystem APIs.
Error responses use the standardized API error payload:
{
"error": {
"message": "Human readable message",
"code": "SOME_MACHINE_CODE",
"details": {}
}
}
401 Unauthorized: no valid session (getSession() returns null).
{ "error": { "message": "Unauthorized", "code": "AUTH_UNAUTHENTICATED" } }
403 Forbidden: session exists but the user is not allowed to access the requested branch.
{ "error": { "message": "Forbidden", "code": "AUTH_FORBIDDEN_BRANCH" } }
RBAC rules live in lib/auth/permissions.js:
canAccessBranch(session, branchId)filterBranchesForSession(session, branchIds)These endpoints require a valid session:
GET /api/branchesGET /api/branches/[branch]/yearsGET /api/branches/[branch]/[year]/monthsGET /api/branches/[branch]/[year]/[month]/daysGET /api/files?branch=&year=&month=&day=GET /api/files/:branch/:year/:month/:day/:filename (binary PDF stream/download)GET /api/search (search is always subject to RBAC)Search RBAC notes:
branch= or scope=multi&branches=... are validated and filtered through RBAC.Sessions are implemented as signed JWTs stored in HTTP-only cookies.
{
"userId": "<MongoDB ObjectId as string>",
"role": "branch | admin | dev",
"branchId": "NL01 | null",
"iat": 1700000000,
"exp": 1700003600
}
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 })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.
Rationale:
401 as basic control flow to determine “am I logged in?”./api/auth/me provides a stable, low-friction session check.Response (unauthenticated):
{ "user": null }
Response (authenticated):
{ "user": { "userId": "...", "role": "branch|admin|dev", "branchId": "NL01" } }
Security note:
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 = nullError responses:
400 VALIDATION_INVALID_JSON400 VALIDATION_INVALID_BODY400 VALIDATION_MISSING_FIELD (details: { fields: [ ... ] })400 VALIDATION_WEAK_PASSWORD401 AUTH_UNAUTHENTICATED401 AUTH_INVALID_CREDENTIALS500 INTERNAL_SERVER_ERRORExample: weak password
{
"error": {
"message": "Weak password",
"code": "VALIDATION_WEAK_PASSWORD",
"details": {
"minLength": 8,
"requireLetter": true,
"requireNumber": true,
"disallowSameAsCurrent": true,
"reasons": ["MIN_LENGTH", "MISSING_NUMBER"]
}
}
}
The 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 B), 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.AUTH_INVALID_CREDENTIALS is used for invalid current password.