This document describes the authentication and authorization model for the internal delivery note browser.
The system uses:
branch, admin, dev).NOTE: This document is a living document. As we extend the auth system (sessions, routes, policies, password flows), we will update this file.
The main goals of the authentication and authorization system are:
This document covers:
The authentication system depends on the following environment variables:
SESSION_SECRET (required)
Example for .env.local.example:
# Session / JWT
SESSION_SECRET=change-me-to-a-long-random-string
If SESSION_SECRET is not set, session utilities will throw an error.
Users are stored in MongoDB using the User collection.
username (String, required, unique, lowercased)
email (String, required, unique, lowercased)
passwordHash (String, required)
role (String, required, enum: "branch" | "admin" | "dev")
branchId (String | null)
"NL01") that the user belongs to.role = "branch".null or unused for non-branch users (admin, dev).mustChangePassword (Boolean, default: false)
true, the user should be forced to set a new password on the next login.passwordResetToken (String | null)
null if there is no active reset request.passwordResetExpiresAt (Date | null)
passwordResetToken.null if there is no active reset request.createdAt (Date, auto-generated)
updatedAt (Date, auto-generated)
username must be unique and is stored in lowercase.email must be unique and is stored in lowercase.passwordHash must be present for all users.role = "branch", branchId must be a non-empty string.role = "admin" and role = "dev", branchId is optional and usually null.passwordResetToken and passwordResetExpiresAt should be consistent:
When converting User documents to JSON or plain objects (e.g. in API responses), the following fields must be hidden:
passwordHashpasswordResetTokenThis ensures that sensitive information is not exposed via API responses or logs.
Users are created by an admin (no public self-registration).
When a user is created:
role is set by the admin.branchId is set by the admin and cannot be chosen or changed by the user.For branch accounts, we typically create one or more users per branch with:
role = "branch"branchId set to the respective branch identifier (e.g. "NL01").The user is provided with an initial password and is encouraged (or forced via mustChangePassword) to change it after the first login.
branchbranchId (e.g. "NL01").Intended access pattern (high-level):
adminbranchId = null).Intended access pattern (high-level):
devbranchId = null).Intended access pattern (high-level):
The backend enforces Role-Based Access Control (RBAC) on branch-related filesystem APIs.
401 Unauthorized: no valid session (getSession() returns null).
{ "error": "Unauthorized" }
403 Forbidden: session exists but the user is not allowed to access the requested branch.
{ "error": "Forbidden" }
Note: Some legacy
400/500messages are still returned in German (e.g. missing params, filesystem errors). We may normalize these later.
RBAC rules are implemented in lib/auth/permissions.js:
canAccessBranch(session, branchId)
falserole = "branch" → true only if session.branchId === branchIdrole = "admin" | "dev" → true for any branchfilterBranchesForSession(session, branchIds)
role = "branch" → returns only the user’s own branch (if present)role = "admin" | "dev" → returns allThe following endpoints are protected and must be called only with a valid session:
GET /api/branches
branch role: returns only [session.branchId]admin/dev: returns all branchesGET /api/branches/[branch]/years
GET /api/branches/[branch]/[year]/months
GET /api/branches/[branch]/[year]/[month]/days
GET /api/files?branch=&year=&month=&day=
Implementation pattern (high-level):
const session = await getSession()!session → return 401params.branch or query.branch)!canAccessBranch(session, requestedBranch) → return 403Sessions are implemented as signed JWTs stored in HTTP-only cookies.
A session payload has the following structure:
{
"userId": "<MongoDB ObjectId as string>",
"role": "branch | admin | dev",
"branchId": "NL01 | null",
"iat": 1700000000,
"exp": 1700003600
}
userId (string): MongoDB _id of the user.role (string): One of "branch", "admin", "dev".branchId (string or null): Branch identifier for branch users, or null for admin/dev users.iat (number): Issued-at timestamp (UNIX time).exp (number): Expiration timestamp (UNIX time).The iat and exp fields are managed by the JWT library.
SESSION_SECRET).HS256 (HMAC using SHA-256).SESSION_SECRET.Token lifetime:
SESSION_MAX_AGE_SECONDS = 60 * 60 * 8 (8 hours).lib/auth/session.js.The session token is stored in an HTTP-only cookie with the following properties:
auth_sessionAttributes:
httpOnly: truesecure: process.env.NODE_ENV === "production"sameSite: "lax"path: "/" (cookie is sent for all paths)maxAge: 8 hours (matching SESSION_MAX_AGE_SECONDS)Cookies are written and cleared using Next.js cookies() from next/headers inside lib/auth/session.js:
createSession({ userId, role, branchId }):
auth_session cookie.getSession():
auth_session cookie.{ userId, role, branchId } or null.null.destroySession():
auth_session cookie by setting an empty value with maxAge: 0.POST /api/auth/loginPurpose
Authenticate a user using username and password, create a session, and set the session cookie.
Method & URL
POST /api/auth/loginRequest Body (JSON)
{
"username": "example.user",
"password": "plain-text-password"
}
username (string): Login name (case-insensitive).password (string): Plaintext password entered by the user.Behavior
Normalize username:
Parse and validate request body:
400 { "error": "Invalid request body" }.username or password is missing or empty → 400 { "error": "Missing username or password" }.Connect to MongoDB.
Look up the user in MongoDB by normalized username.
401 { "error": "Invalid credentials" }.Verify the password using bcrypt:
password with user.passwordHash.401 { "error": "Invalid credentials" }.On success:
Create a session payload { userId, role, branchId }.
Call createSession({ userId, role, branchId }):
auth_session HTTP-only cookie.Return 200 { "ok": true }.
Possible Responses
200 OK:
{
"ok": true
}
400 Bad Request:
{
"error": "Invalid request body"
}
or
{
"error": "Missing username or password"
}
401 Unauthorized:
{
"error": "Invalid credentials"
}
500 Internal Server Error:
{
"error": "Internal server error"
}
GET /api/auth/logoutPurpose Destroy the current session by clearing the session cookie.
Method & URL
GET /api/auth/logoutRequest
Behavior
Call destroySession():
auth_session cookie by setting an empty value with maxAge: 0.Return 200 { "ok": true }.
Logout is idempotent:
{ "ok": true }.Responses
200 OK:
{
"ok": true
}
500 Internal Server Error (if destroySession throws):
{
"error": "Internal server error"
}
The database model is already prepared for password management and password recovery flows, but the respective endpoints may be implemented in a separate epic.
Endpoint
POST /api/auth/change-password (planned)
Purpose Allow logged-in users to change their password by providing the current password and a new password.
Method & URL
POST /api/auth/change-passwordAuthentication
Request Body (JSON)
{
"currentPassword": "old-password",
"newPassword": "new-password"
}
Planned Behavior
userId from the current session (getSession()).currentPassword against passwordHash using bcrypt.400 or 401 with { "error": "Invalid password" }).newPassword with bcrypt.passwordHash in the database.mustChangePassword = false.passwordChangedAt field if introduced later.{ "ok": true }.Endpoint
POST /api/auth/request-password-reset (planned)
Purpose Start the "forgot password" flow by sending a reset link to the user's email address.
Method & URL
POST /api/auth/request-password-resetRequest Body (JSON)
{
"usernameOrEmail": "nl01@company.com"
}
Planned Behavior
Normalize the identifier (trim + lowercase).
Try to find a user by email (and optionally by username).
If no user is found:
{ "ok": true }) to avoid user enumeration.If a user is found:
passwordResetToken.passwordResetExpiresAt to a timestamp in the near future (e.g. now + 30 minutes).Send an email to user.email containing a link like:
https://<app-domain>/reset-password?token=<passwordResetToken>
Always return { "ok": true }.
Endpoint
POST /api/auth/reset-password (planned)
Purpose Complete the password reset process using a valid reset token.
Method & URL
POST /api/auth/reset-passwordRequest Body (JSON)
{
"token": "reset-token-from-email",
"newPassword": "new-password"
}
Planned Behavior
Find user by passwordResetToken.
If no user is found → return a generic error (e.g. { "error": "Invalid or expired token" }).
Check that passwordResetExpiresAt is in the future.
If the token has expired:
passwordResetToken and passwordResetExpiresAt.If the token is valid:
newPassword with bcrypt.passwordHash in the database.passwordResetToken and passwordResetExpiresAt.mustChangePassword = false.Optionally invalidate other active sessions if a "global logout on password change" is implemented.
Return { "ok": true }.
Password reset emails will be sent using a mailer library (e.g. nodemailer), configured for the environment.
Key points:
Emails are sent to user.email.
The content includes:
passwordResetToken.No confidential data (like passwords) is ever sent via email.
Never trust client-provided branch information.
session.branchId) and RBAC rules.branch parameters for URL structure, the backend enforces branch access based on the session.Password handling.
passwordHash or passwordResetToken in API responses.Session security.
httpOnly cookies to protect the session token from JavaScript access.secure cookies in production.sameSite: "lax" or stricter unless cross-site needs are explicitly identified.SESSION_SECRET, rotated when necessary.Brute force and enumeration.
Login and password reset endpoints should:
Auditing and logging.
(Optional) Add a middleware.js for frontend route protection (redirect unauthenticated users to login for certain pages).
Implement password management endpoints:
POST /api/auth/change-passwordPOST /api/auth/request-password-resetPOST /api/auth/reset-passwordIntegrate an email provider using nodemailer or similar for password reset.
Build frontend UI for:
Optional improvements: