auth.md 16 KB

Authentication & Authorization

This document describes the authentication and authorization model for the internal delivery note browser.

The system uses:

  • MongoDB to store users.
  • Cookie-based sessions with a signed JWT payload.
  • Role-aware access control (branch, admin, dev).
  • Extensible password management and recovery flows.

NOTE: This document is a living document. As we extend the auth system (sessions, routes, policies, password flows), we will update this file.


1. Goals & Scope

The main goals of the authentication system are:

  • Only authenticated users can access the application.
  • Branch users can only see delivery notes for their own branch.
  • Admin and dev users can access data across branches.
  • Passwords are never stored in plaintext.
  • Sessions are stored as signed JWTs in HTTP-only cookies.
  • The system is ready for password change and password recovery functionality.

This document covers:

  • User model and roles.
  • Environment variables related to auth.
  • Session payload and cookie configuration.
  • Login and logout endpoints.
  • Planned endpoints for password management and recovery.
  • Security considerations and implementation guidelines.

2. Environment Variables

The authentication system depends on the following environment variables:

  • SESSION_SECRET (required)

    • Strong, random string used to sign and verify JWT session tokens.
    • Must be kept secret and should differ between environments (dev, staging, prod).

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.


3. User Model

Users are stored in MongoDB using the User collection.

3.1 Fields

  • username (String, required, unique, lowercased)

    • Human-chosen login name.
    • Stored in lowercase to enable case-insensitive login.
    • Trimmed, minimum length 3 characters.
    • Unique index to enforce one user per username.
  • email (String, required, unique, lowercased)

    • Contact address used for password recovery and notifications.
    • For branch accounts, this is typically the branch email address.
    • For individual accounts, this can be the personal work email.
    • Stored in lowercase.
    • Unique per user.
  • passwordHash (String, required)

    • Hashed password (e.g. using bcrypt).
    • Plaintext passwords are never stored.
    • Always excluded from JSON serialization.
  • role (String, required, enum: "branch" | "admin" | "dev")

    • Controls the type of access a user has.
    • See Roles section below.
  • branchId (String | null)

    • Identifies the branch (e.g. "NL01") that the user belongs to.
    • Required for role = "branch".
    • Must be null or unused for non-branch users (admin, dev).
  • mustChangePassword (Boolean, default: false)

    • When true, the user should be forced to set a new password on the next login.
    • Useful for first-time login or admin-enforced password resets.
  • passwordResetToken (String | null)

    • Token used for password reset flows.
    • Generated and validated by the backend.
    • Not exposed via public APIs.
    • May be null if there is no active reset request.
  • passwordResetExpiresAt (Date | null)

    • Expiry timestamp for the passwordResetToken.
    • Used to ensure that reset links are only valid for a limited time.
    • May be null if there is no active reset request.
  • createdAt (Date, auto-generated)

    • Timestamp when the user record was created.
  • updatedAt (Date, auto-generated)

    • Timestamp when the user record was last updated.

3.2 Validation Rules & Invariants

  • 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.
  • When role = "branch", branchId must be a non-empty string.
  • For role = "admin" and role = "dev", branchId is optional and usually null.
  • passwordResetToken and passwordResetExpiresAt should be consistent:

    • If one is set, the other should also be set.
    • Once a reset is completed or expired, both should be cleared.

3.3 Serialization Rules

When converting User documents to JSON or plain objects (e.g. in API responses), the following fields must be hidden:

  • passwordHash
  • passwordResetToken

This ensures that sensitive information is not exposed via API responses or logs.

3.4 Role Assignment & User Provisioning

  • 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.


4. Roles

4.1 branch

  • Represents a user who belongs to a specific branch/location.
  • Must have a valid branchId (e.g. "NL01").
  • Intended access pattern (high-level):

    • Can only access delivery notes for their own branch.
    • Cannot access other branches.
    • No global configuration or system-wide administration.

4.2 admin

  • System administrator.
  • Typically not bound to any single branch (branchId = null).
  • Intended access pattern (high-level):

    • Can access delivery notes across all branches.
    • Can perform user administration (create/update users).
    • Can perform configuration-level changes.

4.3 dev

  • Development/engineering account.
  • Used for debugging, maintenance, and operational tooling.
  • Typically not bound to any single branch (branchId = null).
  • Intended access pattern (high-level):

    • Full or near-full access to the system.
    • Can be used in development/staging environments.
    • Production use should be limited and auditable.

5. Sessions & Cookies

Sessions are implemented as signed JWTs stored in HTTP-only cookies.

5.1 Session Payload Format

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.

5.2 JWT Signing

  • JWTs are signed using a symmetric secret (SESSION_SECRET).
  • Algorithm: HS256 (HMAC using SHA-256).
  • Secret is defined via environment variable SESSION_SECRET.
  • Token lifetime:

    • SESSION_MAX_AGE_SECONDS = 60 * 60 * 8 (8 hours).
    • Configured in lib/auth/session.js.

5.3 Cookie Settings

The session token is stored in an HTTP-only cookie with the following properties:

  • Cookie name: auth_session
  • Attributes:

    • httpOnly: true
    • secure: 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 }):

    • Creates and signs a JWT.
    • Sets the auth_session cookie.
  • getSession():

    • Reads the auth_session cookie.
    • Verifies the JWT and returns { userId, role, branchId } or null.
    • If the token is invalid or expired, clears the cookie and returns null.
  • destroySession():

    • Clears the auth_session cookie by setting an empty value with maxAge: 0.

6. Core Auth Endpoints

6.1 POST /api/auth/login

Purpose Authenticate a user using username and password, create a session, and set the session cookie.

Method & URL

  • POST /api/auth/login

Request Body (JSON)

{
	"username": "example.user",
	"password": "plain-text-password"
}
  • username (string): Login name (case-insensitive).
  • password (string): Plaintext password entered by the user.

Behavior

  1. Normalize username:

    • Trim whitespace and convert to lowercase.
  2. Parse and validate request body:

    • If body is missing or invalid JSON → 400 { "error": "Invalid request body" }.
    • If username or password is missing or empty → 400 { "error": "Missing username or password" }.
  3. Connect to MongoDB.

  4. Look up the user in MongoDB by normalized username.

    • If no user is found → 401 { "error": "Invalid credentials" }.
  5. Verify the password using bcrypt:

    • Compare provided password with user.passwordHash.
    • If password does not match → 401 { "error": "Invalid credentials" }.
  6. On success:

    • Create a session payload { userId, role, branchId }.
    • Call createSession({ userId, role, branchId }):

      • Signs a JWT with the session payload.
      • Sets the auth_session HTTP-only cookie.
    • Return 200 { "ok": true }.

Possible Responses

  • 200 OK:

    {
    	"ok": true
    }
    

(Session cookie is set in the response headers.)

  • 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"
    }
    

6.2 GET /api/auth/logout

Purpose Destroy the current session by clearing the session cookie.

Method & URL

  • GET /api/auth/logout

Request

  • No request body.
  • Uses the current session cookie (if present).

Behavior

  1. Call destroySession():

    • Clears the auth_session cookie by setting an empty value with maxAge: 0.
  2. Return 200 { "ok": true }.

Logout is idempotent:

  • If the cookie does not exist, the endpoint still returns { "ok": true }.

Responses

  • 200 OK:

    {
    	"ok": true
    }
    
  • 500 Internal Server Error (if destroySession throws):

    {
    	"error": "Internal server error"
    }
    

7. Password Management & Recovery (Planned)

The database model is already prepared for password management and password recovery flows, but the respective endpoints may be implemented in a separate epic.

7.1 Change Password

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-password

Authentication

  • Requires a valid session (user must be logged in).

Request Body (JSON)

{
	"currentPassword": "old-password",
	"newPassword": "new-password"
}

Planned Behavior

  1. Extract userId from the current session (getSession()).
  2. Load user from MongoDB.
  3. Verify currentPassword against passwordHash using bcrypt.
  4. If verification fails → return a generic error (e.g. 400 or 401 with { "error": "Invalid password" }).
  5. Hash newPassword with bcrypt.
  6. Update passwordHash in the database.
  7. Optionally set mustChangePassword = false.
  8. Optionally update a passwordChangedAt field if introduced later.
  9. Return { "ok": true }.

7.2 Request Password Reset

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-reset

Request Body (JSON)

{
	"usernameOrEmail": "nl01@company.com"
}
  • The frontend may allow either username or email. The backend resolves it accordingly.

Planned Behavior

  1. Normalize the identifier (trim + lowercase).

  2. Try to find a user by email (and optionally by username).

  3. If no user is found:

    • Do not reveal this to the caller.
    • Return a generic success response (e.g. { "ok": true }) to avoid user enumeration.
  4. If a user is found:

    • Generate a secure random token (or a signed token).

    • Store it in passwordResetToken.

    • Set 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>
      
    • The email is sent using a mailer (e.g. nodemailer).

  5. Always return { "ok": true } to the client, regardless of whether a user was found.

7.3 Reset Password

Endpoint POST /api/auth/reset-password (planned)

Purpose Complete the password reset process using a valid reset token.

Method & URL

  • POST /api/auth/reset-password

Request Body (JSON)

{
	"token": "reset-token-from-email",
	"newPassword": "new-password"
}

Planned Behavior

  1. Find user by passwordResetToken.
  2. If no user is found → return a generic error (e.g. { "error": "Invalid or expired token" }).
  3. Check that passwordResetExpiresAt is in the future.
  4. If the token has expired:

    • Return a generic error.
    • Clear passwordResetToken and passwordResetExpiresAt.
  5. If the token is valid:

    • Hash newPassword with bcrypt.
    • Update passwordHash in the database.
    • Clear passwordResetToken and passwordResetExpiresAt.
    • Optionally set mustChangePassword = false.
  6. Optionally invalidate other active sessions if a "global logout on password change" is implemented.

  7. Return { "ok": true }.

7.4 Email Sending

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:

    • A short explanation of the password reset process.
    • A one-time link containing the passwordResetToken.
    • Information about the expiration time.
  • No confidential data (like passwords) is ever sent via email.


8. Security Considerations

  1. Never trust client-provided branchId.

    • The effective branchId for authorization must always come from the session payload (derived from the user record), not from query parameters or request bodies.
    • Even if routes use branch parameters for URL structure, the backend must enforce access based on the branchId in the session.
  2. Password handling.

    • Always hash passwords using a strong algorithm (e.g. bcrypt with a reasonable cost factor).
    • Never log plaintext passwords.
    • Never expose passwordHash or passwordResetToken in API responses.
  3. Session security.

    • Use httpOnly cookies to protect the session token from JavaScript access.
    • Use secure cookies in production.
    • Use sameSite: "lax" or stricter unless cross-site needs are explicitly identified.
    • Use a strong SESSION_SECRET, rotated when necessary.
  4. Brute force and enumeration.

    • Login and password reset endpoints should:

      • Respond with generic error messages (e.g. “Invalid credentials”).
      • Not leak information on whether a user exists.
      • Optionally implement rate limiting or throttling.
  5. Auditing and logging.

    • Sensitive operations (login failures, password changes, password reset requests) should be logged with appropriate details, without exposing secrets.
    • Logs must not contain plaintext passwords or reset tokens.

9. Future Work & Integration

  • Protect existing filesystem APIs (/api/branches/*, /api/files, etc.) by:

    • Calling getSession() at the start of each route.
    • Returning 401 if no valid session exists.
    • Resolving the effective branchId from the session and enforcing that branch users only see their own branch.
  • Implement password management endpoints:

    • POST /api/auth/change-password
    • POST /api/auth/request-password-reset
    • POST /api/auth/reset-password
  • Integrate an email provider using nodemailer or similar for password reset.

  • Build frontend UI for:

    • Login
    • Logout
    • Change password
    • “Forgot password” / reset password flows.
  • Optionally extend auditing and logging for security-relevant events.