# 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`: ```env # 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: ```json { "userId": "", "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)** ```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`: ```json { "ok": true } ``` (Session cookie is set in the response headers.) - `400 Bad Request`: ```json { "error": "Invalid request body" } ``` or ```json { "error": "Missing username or password" } ``` - `401 Unauthorized`: ```json { "error": "Invalid credentials" } ``` - `500 Internal Server Error`: ```json { "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`: ```json { "ok": true } ``` - `500 Internal Server Error` (if `destroySession` throws): ```json { "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)** ```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)** ```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:///reset-password?token= ``` - 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)** ```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.