Full-codebase-audit 2026-05-30 outputs: - Audit report: 09 - Audits/Full Codebase Audit - 2026-05-30.md - 81 issue files ISSUE-055..135 (decisions + 1 skipped no-brainer). - Scanner docs from scratch (was zero): architecture, data model, API ref, payment flow, operations runbook + repo README. - Doc-sync updates across API reference, data models, flows, design system. - Secret Rotation Runbook (08 - Operations) for the exposed credentials. - Reusable workflow guide (07 - Development) + .claude/workflows/full-codebase-audit.js. Issues remain status:open intentionally — the code fixes are uncommitted-then-committed working-tree changes per repo and aren't "resolved" until merged/deployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
16 KiB
Markdown
218 lines
16 KiB
Markdown
---
|
||
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. **Cloudflare Turnstile CAPTCHA gate** (`captchaGate` middleware, commit `b8edbbf`): Before the rate-limiter runs, `captchaGate` checks the in-memory failure counter for the caller's IP. If that IP has accumulated **3 or more failed login attempts** within 15 minutes, a valid `cf-turnstile-response` token must be present in the request body. Without it the endpoint returns `429 { captchaRequired: true }`. If `TURNSTILE_SECRET_KEY` is not set (local dev), the gate is skipped. On CAPTCHA pass, the middleware calls Cloudflare's `siteverify` endpoint to validate the token before proceeding.
|
||
5a. **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`
|