-
Notifications
You must be signed in to change notification settings - Fork 0
Authentication
-
Access token: signed with
TOKEN_SECRET, expiryTOKEN_EXP_IN(e.g.3h). -
Refresh token: signed with
TOKEN_REFRESH, expiryTOKEN_REFRESH_EXP_IN(defaults to7dif 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/refreshrotates 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.
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
csrfTokencookie is issued/cleared alongside the auth cookies. - Any state-changing request that authenticated purely off the cookie (no
Authorizationheader, no token in the body/query) must also send that same value back — checked inverifyToken.middleware.tsfor every route already behind it, and via a standalonecsrf.middleware.tson the two auth routes that bypassverifyTokenentirely:POST /auth/refreshandPOST /auth/logout. - A request authenticating via the
Authorization: Bearerheader 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/loginis intentionally unguarded — no session cookie exists yet before login succeeds.
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.
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.
Applied to every /api/v1/* route except /auth/*. On a valid token it:
- Checks the blacklist and the session-revocation timestamp.
- Enforces the CSRF check described above, if applicable.
- Attaches
req.user = { _id }. -
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).
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.
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", ...}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).