auth.md 9.8 KB

Authentication & Authorization

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

The system uses:

  • MongoDB to store users (via Mongoose models).
  • Cookie-based sessions with a signed JWT payload.
  • Role-aware access control (branch, admin, dev).
  • Branch-level RBAC enforcement for filesystem-related APIs.

1. Goals & Scope

The main goals of the authentication and authorization system are:

  • Only authenticated users can access protected backend APIs.
  • 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 in signed JWTs in HTTP-only cookies.

This document covers:

  • Environment variables related to auth.
  • Roles and RBAC rules.
  • Session payload and cookie configuration.
  • Auth endpoints (login/logout/me).
  • Password change (authenticated users).

Non-goals (for this document):

  • Email-based password recovery (documented as a follow-up ticket/phase).

2. Environment & Configuration

2.1 Required variables

Auth depends on the following environment variables:

  • SESSION_SECRET (required)
    • Strong, random string used to sign and verify JWT session tokens.
    • Minimum length: 32 characters.
    • Must be kept secret.
    • Should differ between environments (dev/staging/prod).

Auth endpoints also require DB connectivity:

  • MONGODB_URI (required)

2.2 Optional variables

  • SESSION_COOKIE_SECURE (optional)
    • Overrides the Secure cookie flag.
    • Allowed values: 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):

  • Set SESSION_COOKIE_SECURE=false in your local docker env file.

Staging/Production:

  • Prefer HTTPS.
  • Keep 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 Secure cookies back. In that case, logins will appear to “work” (Set-Cookie is present), but subsequent requests will still be unauthenticated.

2.3 Fail-fast environment validation

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

3. Roles

3.1 branch

  • Represents a user who belongs to a specific branch/location.
  • Must have a valid branchId (e.g. "NL01").
  • Intended access pattern:
    • Can only access delivery notes for their own branch.
    • Cannot access other branches.

3.2 admin

  • Administrator account.
  • Typically not bound to any single branch (branchId = null).
  • Intended access pattern:
    • Can access delivery notes across all branches.

3.3 dev

  • Development/engineering account.
  • Typically not bound to any single branch (branchId = null).
  • Intended access pattern:
    • Full or near-full access.

4. Authorization: Branch-Level RBAC

RBAC is enforced on branch-related filesystem APIs.

4.1 Response semantics

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

4.2 Permission helpers

RBAC rules live in lib/auth/permissions.js:

  • canAccessBranch(session, branchId)
  • filterBranchesForSession(session, branchIds)

4.3 Protected endpoints

These endpoints require a valid session:

  • GET /api/branches
  • GET /api/branches/[branch]/years
  • GET /api/branches/[branch]/[year]/months
  • GET /api/branches/[branch]/[year]/[month]/days
  • GET /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 users can only search within their own branch.
  • Admin/dev can search across multiple branches.
  • Query params like branch= or scope=multi&branches=... are validated and filtered through RBAC.

5. Sessions & Cookies

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

5.1 Session payload

{
	"userId": "<MongoDB ObjectId as string>",
	"role": "branch | admin | dev",
	"branchId": "NL01 | null",
	"iat": 1700000000,
	"exp": 1700003600
}

5.2 JWT signing

  • Algorithm: HS256.
  • Secret: SESSION_SECRET.
  • Token lifetime: SESSION_MAX_AGE_SECONDS = 8 hours.

5.3 Cookie settings

Cookie name: auth_session

Attributes:

  • httpOnly: true
  • secure: resolved via NODE_ENV + optional SESSION_COOKIE_SECURE override
  • sameSite: "lax"
  • path: "/"
  • maxAge: 8 hours

Implementation lives in lib/auth/session.js:

  • createSession({ userId, role, branchId })
  • getSession()
  • destroySession()

6. Auth Endpoints

All endpoints below are implemented as Next.js App Router Route Handlers in app/api/**/route.js.

6.1 POST /api/auth/login

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

6.2 GET /api/auth/logout

Clears the session cookie.

  • Returns 200 { "ok": true } on success.
  • Logout is idempotent.

6.3 GET /api/auth/me

Return the current session identity for frontend consumers.

Rationale:

  • Frontends should not use 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:

  • The endpoint intentionally returns only the minimal session identity.
  • It does not reveal password hashes or user database internals.

7. Password Management

7.1 Password storage

  • Passwords are stored as a bcrypt hash in users.passwordHash.
  • The API never returns passwordHash.

7.2 Password policy (current)

The password policy is intentionally explicit and testable.

Current policy:

  • Minimum length: 8 characters
  • Must contain at least 1 letter (A–Z)
  • Must contain at least 1 number (0–9)
  • New password must be different from the current password

The canonical policy implementation lives in lib/auth/passwordPolicy.js.

7.3 POST /api/auth/change-password (RHL-009)

Change the password for the currently authenticated user.

Authentication:

  • Requires a valid session cookie.
  • If no session exists: 401 AUTH_UNAUTHENTICATED.

Request body (JSON):

{
	"currentPassword": "<string>",
	"newPassword": "<string>"
}

Response:

  • 200 { "ok": true }

Behavior:

  1. Validate JSON and required fields.
  2. Load the current user by session.userId.

    • If the user cannot be found: treat as invalid session and return 401 AUTH_UNAUTHENTICATED.
  3. Verify that currentPassword matches passwordHash.

    • If mismatch: return 401 AUTH_INVALID_CREDENTIALS.
  4. Validate newPassword against the password policy.

    • If weak: return 400 VALIDATION_WEAK_PASSWORD with structured details.
  5. Hash and persist the new password.

  6. Clear flags / sensitive reset state:

    • mustChangePassword = false
    • passwordResetToken = null
    • passwordResetExpiresAt = null

Error responses:

  • 400 VALIDATION_INVALID_JSON
  • 400 VALIDATION_INVALID_BODY
  • 400 VALIDATION_MISSING_FIELD (details: { fields: [ ... ] })
  • 400 VALIDATION_WEAK_PASSWORD
  • 401 AUTH_UNAUTHENTICATED
  • 401 AUTH_INVALID_CREDENTIALS
  • 500 INTERNAL_SERVER_ERROR

Example: weak password

{
	"error": {
		"message": "Weak password",
		"code": "VALIDATION_WEAK_PASSWORD",
		"details": {
			"minLength": 8,
			"requireLetter": true,
			"requireNumber": true,
			"disallowSameAsCurrent": true,
			"reasons": ["MIN_LENGTH", "MISSING_NUMBER"]
		}
	}
}

7.4 Password reset (planned, separate ticket)

The user model includes fields that allow implementing password reset flows:

  • mustChangePassword
  • passwordResetToken
  • passwordResetExpiresAt

However, 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.


8. Security Notes

  • Use HTTPS for real users (staging/prod).
  • Keep SESSION_SECRET secret and rotate when needed.
  • Local HTTP testing is supported via SESSION_COOKIE_SECURE=false.
  • Password-changing endpoints should avoid leaking sensitive information:
    • AUTH_INVALID_CREDENTIALS is used for invalid current password.
    • Weak-password responses should return only safe, structured details (policy + reasons).