Files
nick-doc/04 - Flows/Authentication Flow.md
2026-05-23 20:35:34 +03:30

188 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: Authentication Flow
tags: [flow, auth, jwt, login, security]
related_models: ["[[User]]", "[[TempVerification]]"]
related_apis: ["[[Auth API]]", "POST /api/auth/login", "POST /api/auth/refresh-token", "POST /api/auth/logout"]
---
# Authentication Flow
End-to-end specification for **email + password** authentication, JWT issuance, token lifecycle, refresh-token rotation, and logout cleanup. This is the most-used auth path in the marketplace and underpins every protected API call and Socket.IO room subscription.
## Actors
- **User (Buyer / Seller / Admin)** submits credentials via the frontend.
- **Frontend (Next.js)** the JWT sign-in view in `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`, which delegates to `signInWithPassword()` in `frontend/src/auth/context/jwt/action.ts`.
- **Backend (Express)** `AuthController.login` in `backend/src/services/auth/authController.ts`.
- **MongoDB** `User` collection (refresh tokens are appended to `user.refreshTokens[]`).
- **Redis** session store (`sessionService.createSession`) and login-attempt rate-limiter (`rateLimitService.checkLoginAttempts`).
- **Socket.IO** not directly involved in login, but the issued JWT is later used to join `user-${userId}` rooms (see [[Notification Flow]] and [[Chat Flow]]).
## Preconditions
- The user has already completed [[Registration Flow]] and their `User.isEmailVerified === true`.
- The user's `User.status === "active"` (soft-deleted accounts have status `"deleted"`).
- Network connectivity is available (the frontend uses `NetworkUtils.isOnline()` from `frontend/src/auth/utils/error-handler.ts`).
- `localStorage` is available — the frontend rejects the request early via `StorageUtils.isAvailable()` if storage is blocked.
- Backend env vars `JWT_SECRET`, `JWT_EXPIRES_IN`, `REFRESH_TOKEN_EXPIRES_IN` are set (`backend/src/services/auth/authService.ts:19-21`).
## Step-by-step narrative
1. **User fills the sign-in form** at `/auth/jwt/sign-in` and clicks "Sign in". The form is implemented in `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`.
2. **Client-side guards**: `signInWithPassword()` (`action.ts:32-116`) verifies the browser is online and `localStorage` is writable; otherwise it throws a typed `AuthErrorHandler` error.
3. **HTTP request**: The frontend POSTs `{ email, password }` to `POST /api/auth/login` (resolved by `endpoints.auth.login` in `frontend/src/lib/axios.ts`). An `AbortController` is armed with a 60-second timeout.
4. **Validation middleware** runs `loginValidation` (`backend/src/services/auth/authValidation.ts`) — wires into Express via `authRoutes.ts:22`.
5. **Rate limiting**: `rateLimitService.checkLoginAttempts(email, 5, 15*60)` is called (`authController.ts:173`). Five failures within 15 minutes returns `429 TOO_MANY_ATTEMPTS`. Counters live in Redis so they survive restarts.
6. **User lookup**: `User.findOne({ email, status: "active" }).select("+password")``password` is `select: false` by default in the schema and must be explicitly projected.
7. **Password comparison**: `authService.comparePassword()` invokes `bcrypt.compare()` (cost factor 12 — see `authService.ts:102-105`). Constant-time per bcrypt's design.
8. **Email-verification gate**: If `!user.isEmailVerified`, returns `403 EMAIL_NOT_VERIFIED` with `needsVerification: true`. The frontend intercepts this in `action.ts:104-111` and redirects to `/auth/jwt/verify?email=...`.
9. **Reset attempts**: On success, `rateLimitService.resetLoginAttempts(email)` wipes the Redis counter.
10. **Last-login stamp**: `user.lastLoginAt = new Date()` is saved.
11. **Token issuance**:
- **Access token** — `authService.generateToken()` builds a JWT with `{ id, email, role, isEmailVerified, iat }`, signed with `HS256`, `issuer: 'marketplace-backend'`, `audience: 'marketplace-users'`, default `expiresIn` from config.
- **Refresh token** — `authService.generateRefreshToken()` issues a separate JWT with `{ id, type: 'refresh' }` and a longer TTL.
12. **Refresh-token persistence**: The new refresh token is appended to the `User.refreshTokens` array (`authController.ts:230-231`). This array is the server-side allow-list — only tokens present here can be used to mint new access tokens.
13. **Redis session**: `sessionService.createSession(accessToken, userId, email, role, ip, userAgent, 86400)` stores a 24h session record keyed by the access token (`authController.ts:235-247`). Failures are logged but do **not** block login.
14. **Response**: `200 OK` with `{ user: user.toJSON(), tokens: { accessToken, refreshToken } }`. `toJSON()` strips `password`, `refreshTokens`, and verification codes (see User model `toJSON` transform).
15. **Client-side storage**: The frontend writes both tokens to `localStorage` via `StorageUtils.safeSet()` — keys `accessToken` and `refreshToken` (`action.ts:77-82`).
> [!warning] Token storage is `localStorage`, not cookies
> Tokens are persisted in `window.localStorage`. This is intentional (the API is a separate origin and the app is fully SPA-like), but it means tokens are reachable from any script running on the page. Mitigations in place: strict CSP via `helmet`, no third-party scripts in the auth views, and short access-token TTL with refresh rotation. There are **no httpOnly auth cookies**.
16. **Axios interceptor** (`frontend/src/lib/axios.ts`) attaches `Authorization: Bearer ${accessToken}` to every subsequent request and, on `401/403`, automatically calls the refresh flow described below.
17. **Socket.IO bootstrap**: After login, the dashboard layout connects to Socket.IO and emits `join-user-room`, `join-buyer-room`/`join-seller-room` based on `user.role`. See `backend/src/app.ts:83-126`.
## Sequence diagram
```mermaid
sequenceDiagram
autonumber
actor U as User
participant FE as Frontend (Next.js)
participant BE as Backend (Express)
participant DB as MongoDB
participant R as Redis
participant IO as Socket.IO
U->>FE: Enter email + password, click Sign in
FE->>FE: NetworkUtils.isOnline() / StorageUtils.isAvailable()
FE->>BE: POST /api/auth/login { email, password }
BE->>R: rateLimitService.checkLoginAttempts(email)
R-->>BE: { allowed: true, remaining }
BE->>DB: User.findOne({ email, status: "active" }).select("+password")
DB-->>BE: user document
BE->>BE: bcrypt.compare(password, user.password)
alt password invalid
BE-->>FE: 401 Invalid credentials
else email not verified
BE-->>FE: 403 EMAIL_NOT_VERIFIED
FE-->>U: Redirect /auth/jwt/verify
else success
BE->>R: rateLimitService.resetLoginAttempts(email)
BE->>DB: user.lastLoginAt = now; user.refreshTokens.push(refresh)
BE->>BE: generateToken(authUser) / generateRefreshToken(authUser)
BE->>R: sessionService.createSession(accessToken, ...)
BE-->>FE: 200 { user, tokens: { accessToken, refreshToken } }
FE->>FE: localStorage.setItem('accessToken' / 'refreshToken')
FE->>IO: socket.emit('join-user-room', userId)
FE-->>U: Redirect to /dashboard
end
```
## API calls
| Method | Endpoint | Source |
|---|---|---|
| `POST` | `/api/auth/login` | `backend/src/services/auth/authRoutes.ts:22``authController.login` |
| `POST` | `/api/auth/refresh-token` | `authRoutes.ts:24-27``authController.refreshToken` |
| `POST` | `/api/auth/logout` | `authRoutes.ts:68``authController.logout` (protected) |
| `GET` | `/api/auth/profile` | `authRoutes.ts:69``authController.getProfile` |
## Database writes
- **`users` collection**: `lastLoginAt` updated; `refreshTokens` array gains one entry per successful login or refresh.
- No write at all on a failed attempt (only Redis counter increments).
- On `changePassword` / `resetPassword`: `refreshTokens` is reset to `[]` (forces re-login on every device — `authController.ts:600` and `:685`).
## Socket events emitted
- Login itself emits nothing. After the response, the frontend emits the **client-side** events `join-user-room`, `join-buyer-room` or `join-seller-room` to subscribe to targeted notifications.
## Side effects
- **Redis session**: 24-hour key holding `{ userId, email, role, ip, userAgent }`. Used for forced logout (admin can call `sessionService.deleteSession(token)`).
- **Redis rate-limit counter**: TTL 15 min, reset on success.
- **No email** is sent on a normal login (no "new sign-in" notification today — opportunity for future enhancement).
- **Sentry**: any unexpected exception bubbles to `Sentry.setupExpressErrorHandler` (`app.ts:351`).
## Refresh-token flow
The access token is short-lived. When a protected request returns `401 TOKEN_INVALID` or `403`, the axios interceptor calls:
1. `POST /api/auth/refresh-token` with `{ refreshToken }` from `localStorage`.
2. Backend `authController.refreshToken` (`:263-313`) verifies the token via `verifyRefreshToken`, checks it is **still present in `user.refreshTokens[]`**, then issues a brand-new access **and** refresh token.
3. The old refresh token is **removed** from the array and the new one is pushed — implementing **refresh-token rotation**. A leaked-but-stale token therefore becomes invalid the moment the legitimate user refreshes.
4. The new pair is written back to `localStorage` and the original failed request is retried.
```mermaid
sequenceDiagram
autonumber
participant FE as Frontend axios
participant BE as Backend
participant DB as MongoDB
FE->>BE: GET /api/marketplace/... (Bearer access)
BE-->>FE: 401 TOKEN_INVALID
FE->>BE: POST /api/auth/refresh-token { refreshToken }
BE->>BE: verifyRefreshToken(refreshToken)
BE->>DB: User.findById(decoded.id); ensure refresh ∈ user.refreshTokens
BE->>BE: Generate new access + refresh tokens
BE->>DB: user.refreshTokens = [...minus old, new]
BE-->>FE: 200 { tokens: { accessToken, refreshToken } }
FE->>BE: GET /api/marketplace/... (Bearer new access) — retry
```
## Logout flow
1. Frontend `signOut()` (`action.ts:146-176`) reads `refreshToken` from `localStorage` and POSTs `/api/auth/logout` with a 10-second timeout.
2. Backend `authController.logout` (`:316-344`) removes the refresh token from `user.refreshTokens[]` and calls `sessionService.deleteSession(accessToken)`.
3. **Always-clear**: the frontend's `finally` block removes both `accessToken` and `refreshToken` from `localStorage` regardless of network success — meaning even an offline logout effectively signs the user out locally.
> [!tip] Force-logout an entire user
> Setting `user.refreshTokens = []` in MongoDB instantly invalidates all sessions on next refresh. `changePassword`, `resetPassword`, and `deleteAccount` all do this.
## Error / edge cases
- **Wrong password** → `401 Invalid credentials` (intentionally vague — no distinction between "unknown email" and "wrong password").
- **Email unverified** → `403 EMAIL_NOT_VERIFIED`; frontend auto-redirects to verify page.
- **5+ failures in 15 min** → `429 TOO_MANY_ATTEMPTS`; only an admin can manually clear via Redis.
- **Network timeout** → axios `AbortController` cancels at 60s; frontend shows a typed error and the user can retry.
- **Redis down** → login still succeeds (session creation is best-effort, wrapped in try/catch at `authController.ts:234-247`). Rate limiting falls back to the in-memory map in `authService.ts:113-145` if `rateLimitService` itself throws.
- **Stale refresh token** (rotated by another device) → `403 Invalid refresh token`. Frontend signs out and redirects to sign-in.
- **JWT signature mismatch** (secret rotated) → all sessions invalidated server-side; clients clear tokens on first 401.
- **Token issued for another audience/issuer** → `verifyToken` returns `null` (`authService.ts:60-79`), middleware returns `403 TOKEN_INVALID`.
- **Refresh token used as access token** → blocked by the `if (decoded.type === 'refresh') return null` check in `verifyToken` (`authService.ts:67`). This is critical: a leaked refresh token alone cannot read protected data.
- **Soft-deleted account** → `User.findOne({ status: "active" })` filter excludes deleted accounts; login fails as if the email did not exist.
> [!warning] Constant-time response is approximate
> Today we return `401` immediately when the user is missing, before running bcrypt. This is a timing oracle that lets an attacker enumerate registered emails by response-time analysis. Mitigation tracked separately — the recommendation is to always run a dummy bcrypt compare on missing users.
## Linked flows
- [[Registration Flow]] — produces the `User` document this flow consumes.
- [[Password Reset Flow]] — alternate entry into the account if credentials are lost.
- [[Google OAuth Flow]] — parallel auth path that produces equivalent tokens.
- [[Passkey (WebAuthn) Flow]] — passwordless alternative.
- [[Chat Flow]], [[Notification Flow]] — both consume the access token to authorise Socket.IO rooms.
## Source files
- Backend: `backend/src/services/auth/authController.ts:161-260`
- Backend: `backend/src/services/auth/authService.ts:24-99`
- Backend: `backend/src/services/auth/authRoutes.ts:22`
- Backend: `backend/src/services/redis/sessionService.ts`
- Backend: `backend/src/services/redis/rateLimitService.ts`
- Frontend: `frontend/src/auth/context/jwt/action.ts:32-176`
- Frontend: `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx`
- Frontend: `frontend/src/lib/axios.ts` (interceptor + endpoints)