--- 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"] --- > **Last updated:** 2026-05-29 — aligned with code (see Doc vs Code Audit Report) > [!caution] Audit note — last reviewed 2026-05-29 > Several discrepancies between frontend code, backend code, and this document were identified and corrected in this revision. See inline ⚠️ markers for known bugs and mismatches. # 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`). The counter is incremented on **every login attempt** (before password comparison), not only on failures. Once 5 total attempts accumulate within a 15-minute window, the endpoint returns `429 TOO_MANY_ATTEMPTS`. The counter is reset upon a fully successful login (step 9). 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. On a `401` response, the interceptor automatically triggers the refresh flow described below. A `403` response (e.g., `EMAIL_NOT_VERIFIED`) is **not** retried via refresh — it is surfaced directly to the caller. The interceptor only checks `status === 401` (`axios.ts:105`); 403 responses are not handled by the interceptor and propagate as errors. 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: set user.lastLoginAt = now BE->>DB: save new refresh token 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/telegram` | `authRoutes.ts` → `authController.telegramAuth` | | `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` | | `DELETE` | `/api/auth/account` | `authRoutes.ts:86-89` → `authController.deleteAccount` (requires `password` in body, runs `deleteAccountValidation`) | ## Telegram first-class auth flow Telegram is now a peer auth provider alongside email/password, Google, and passkeys. 1. The Telegram Mini App shell reads raw signed launch data from `window.Telegram.WebApp.initData`; browser login can submit a Telegram Login Widget payload. 2. The frontend posts the raw signed payload to `POST /api/auth/telegram`; it never trusts `initDataUnsafe` for authentication. 3. Backend verifies the Telegram signature: - Mini App uses `verifyMiniAppInitData()` with the Telegram WebAppData HMAC algorithm. - Login Widget uses `verifyTelegramLoginWidget()` with the Telegram Login Widget HMAC algorithm. 4. Backend rejects stale `auth_date`, bot accounts, replayed Mini App `initData`, and blocked `TelegramLink` records. 5. If an active `TelegramLink` exists, backend signs in that Amanat user. If no link exists, backend creates a Telegram-only user with nullable email, `authProvider: "telegram"`, `telegramVerified: true`, then creates the link. 6. Backend issues the standard access token and refresh token pair. The frontend stores them in `localStorage` under `accessToken` and `refreshToken`, then hydrates the current session. 7. If `isNewUser=true`, the Mini App opens a lightweight onboarding overlay and points the user to account settings for optional email, language, currency, and wallet details. High-risk actions are unchanged: escrow release, refund, dispute-sensitive, and wallet-sensitive operations still use the existing protected backend authorization and step-up gates. Telegram auth only establishes the user session. ## Passkey auth flow The frontend `registerPasskey` and `authenticateWithPasskey` actions call passkey API endpoints. All passkey API calls are proxied directly to the Express backend via the `next.config.ts` rewrite rule (`/api/:path*` → backend). There are no Next.js route handler files (`route.ts`) for passkey paths — requests travel: browser → Next.js dev server (rewrite) → Express backend. ## 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. Counter increments on every attempt regardless of outcome. - **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`, 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. > [!note] 403 responses are not retried > The interceptor only triggers token refresh for `status === 401` (`axios.ts:105`). A `403` (e.g., `EMAIL_NOT_VERIFIED`) is passed through directly to the caller without attempting a refresh. > [!warning] Refresh-token sequence diagram is truncated > The Mermaid diagram below is **incomplete** — it was truncated in the original source at the point where the backend checks that the refresh token exists in `user.refreshTokens`. The remaining steps (rotate tokens, persist, respond, retry original request) are described in prose above but are not yet rendered in the diagram. ```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) BE->>DB: ensure refresh token is in user.refreshTokens Note over BE,DB: (diagram truncated — remaining steps documented in prose above) ``` ## Account management ### changePassword (API-only) `POST /api/auth/change-password` exists on the backend and the `changePassword()` action is defined in `frontend/src/auth/context/jwt/action.ts`. However: > [!warning] No frontend UI for change-password > There is **no dashboard page** that renders a change-password form. The feature is **API-only** at this time. Users cannot change their password through the UI; a developer or direct API client must call the endpoint manually. ### deleteAccount > [!bug] Account deletion frontend calls wrong endpoint > The frontend `deleteAccount` action calls `DELETE /user/profile`, which does **not exist** on the backend. The real backend endpoint is `DELETE /api/auth/account` (`authRoutes.ts:86-89`), which requires a `password` field in the request body and runs `deleteAccountValidation` middleware. Until the frontend is updated to call the correct endpoint, account deletion will always fail with a 404 or routing error. ## Known issues summary | Issue | Severity | Details | |---|---|---| | `deleteAccount` calls wrong endpoint | Bug | Frontend calls `DELETE /user/profile`; correct backend endpoint is `DELETE /api/auth/account` (requires `password` in body) | | No change-password UI | Gap | `POST /api/auth/change-password` and `changePassword()` action exist but no dashboard page renders the form | | Rate limiter counts all attempts | Clarification | Counter increments before password check — 5 total attempts (not 5 failures) triggers lockout | | Axios interceptor 401-only | Clarification | Interceptor only auto-refreshes on `status === 401` (`axios.ts:105`); 403 errors propagate directly to caller | | Refresh-token diagram truncated | Doc debt | Mermaid diagram cut off mid-flow; prose description is authoritative | ## Linked flows - [[Registration Flow]] — prerequisite; user must be verified. - [[Password Reset Flow]] — alternative credential recovery path. - [[Notification Flow]] — uses the issued JWT for Socket.IO room subscriptions. - [[Chat Flow]] — same JWT used for chat room access. ## Source files - Backend: `backend/src/services/auth/authController.ts` - Backend: `backend/src/services/auth/authService.ts` - Backend: `backend/src/services/auth/authValidation.ts` - Backend: `backend/src/services/auth/authRoutes.ts` - Frontend: `frontend/src/auth/view/jwt/jwt-sign-in-view.tsx` - Frontend: `frontend/src/auth/context/jwt/action.ts` - Frontend: `frontend/src/lib/axios.ts`