Skip to content

Authentication

_david edited this page Sep 26, 2026 · 2 revisions

Authentication

Tokens

  • Access token: signed with TOKEN_SECRET, expiry TOKEN_EXP_IN (e.g. 3h).
  • Refresh token: signed with TOKEN_REFRESH, expiry TOKEN_REFRESH_EXP_IN (defaults to 7d if unset).
  • Both carry { _id: candidateId } as the payload.
  • On login/refresh, both tokens are set as httpOnly cookies and returned in the response body (dual, transitional — issue #119).
  • POST /auth/refresh rotates both tokens and blacklists the old refresh token — reusing a rotated-out refresh token is rejected.

Until 2026-08-21, TOKEN_EXP_IN was declared but never actually passed to the JWT signer, so both tokens silently defaulted to a 1-hour expiry each. Fixed; both now honor their configured/default expiries independently.

Cookie auth + CSRF (issues #119, #134)

The auth cookies use sameSite: 'none' + secure: true (needed because the real deployment — a GitHub Pages frontend calling a Render API — is genuinely cross-site; sameSite: 'strict' would never attach). sameSite: 'none' removes the incidental CSRF protection 'strict' gave for free, so a real double-submit check replaces it:

  • A non-httpOnly csrfToken cookie is issued/cleared alongside the auth cookies.
  • Any state-changing request that authenticated purely off the cookie (no Authorization header, no token in the body/query) must also send that same value back — checked in verifyToken.middleware.ts for every route already behind it, and via a standalone csrf.middleware.ts on the two auth routes that bypass verifyToken entirely: POST /auth/refresh and POST /auth/logout.
  • A request authenticating via the Authorization: Bearer header is not subject to the CSRF check — a third-party page can't set that header on a victim's behalf, so it's already immune.
  • POST /auth/login is intentionally unguarded — no session cookie exists yet before login succeeds.

Session revocation ("logout of all devices", issue #74)

POST /auth/logout-all bumps a per-candidate "invalidated before" timestamp (Redis/mem, same shape as the token blacklist). verifyToken compares this against the JWT's own iat on every request and rejects any token issued before that timestamp — even one that hasn't been individually blacklisted or naturally expired yet. No Candidate schema change, no extra Mongo lookup per request.

Token blacklist

utils/tokenBlacklist.ts — Redis-backed with an in-memory Map fallback if Redis is unavailable. TTL matches the token's remaining lifetime. A background cleanup job runs every 60s for the in-memory fallback path.

verifyToken middleware

Applied to every /api/v1/* route except /auth/*. On a valid token it:

  1. Checks the blacklist and the session-revocation timestamp.
  2. Enforces the CSRF check described above, if applicable.
  3. Attaches req.user = { _id }.
  4. Forces req.body.candidateId = req.user._id, overwriting whatever the client sent. Deliberate — see Security for why.

verifyTokenByQuery is the same check but also accepts the token via ?token= (used by /download-pdf, since browsers can't set custom headers on a direct-link download).

Password reset / email verification (stubs)

POST /auth/forgot-password and POST /auth/reset-password implement the token issue/consume flow end-to-end, but no email is actually sent yet — the reset token is only logged server-side (issue #70). Same for GET /auth/verify-email (issue #71): the verification token is logged, not emailed, and Candidate.emailVerified doesn't gate login — it's informational only, for the frontend to decide what to show.

i18n

Send Accept-Language: en to get English messages; omit the header (or send vi) for Vietnamese (the default). Coverage has grown well past the original "auth only" phase-1 scope — src/locales/{vi,en}.ts now carries 100+ keys spanning auth, candidate self-service, every CV section's create/update/delete/restore, and image/CV/LinkedIn-export upload errors.

# English
curl -X POST .../api/v1/auth/login -H "Accept-Language: en" -d '{"email":"x","password":"wrong-but-valid-format"}'
# → {"message":"Incorrect password", ...}

# Vietnamese (default)
curl -X POST .../api/v1/auth/login -d '{"email":"x","password":"wrong-but-valid-format"}'
# → {"message":"Mật khẩu không chính xác", ...}

Password requirements

Enforced via Joi (config/joi.config.ts) and regex (config/regex.config.ts): minimum 12 characters, at least one uppercase, one lowercase, one number, one special character. Hashed with bcrypt at 12 rounds (utils/bcrypt.ts).

Clone this wiki locally