§ unswayed-backend
Authentication
Authentication
This is the first real feature on top of the scaffold: a complete email/password authentication foundation. It also brought the project's first database (PostgreSQL + Prisma) and config layer. Social/OAuth login has its own chapter (see Social login) — this page is the manual for the email/password core.
The shape of a user
The model mirrors the legacy app: one User row holds the shared auth fields
(email, username, phone — all unique — plus passwordHash, userType,
status, isVerified, isCompleted, authProvider), and each user has exactly
one profile: an Applicant or an Employer (1:1). Two more tables support the
flows: RefreshToken (hashed, for rotation/revocation) and OtpCode (hashed
one-time codes).
Why one User instead of two separate tables? Because uniqueness of
email/username/phone is global across both kinds of account, and almost every other
feature (posts, chat, follows…) refers to "a user" regardless of type. userType
discriminates; the profile carries the type-specific fields.
passwordHashis nullable andauthProviderdefaults to"manual"on purpose — that's the seam social login uses (a Google-only account has no password).
The token model
We wanted both the scale of stateless tokens and the revocability of server-side sessions, so:
- Access token — a JWT (default 24h — raised from 15m for developer
experience; see
docs/DECISIONS.mdADR-0039 / ADR-0011). Verified statelessly by a passport strategy on every protected request; it also re-loads the user from the DB so a deleted user can't keep acting on a valid token. The strategy pins the signing algorithm (HS256) and checks the token'stypeclaim, so a refresh or reset token can never be replayed as an access token. Tradeoff: a longer-lived access token is stateless and not individually revocable (only the refresh rows below are) — tune it per-environment viaJWT_ACCESS_TTL. - Refresh token — a long-lived JWT (7d) whose sha-256 hash is stored in
RefreshToken. On/refreshit is rotated: the old row is revoked and a new pair is issued. On/logout(or a password change) it is revoked. It is passed as a request-body field (refresh_token), not a header.
The interesting part is reuse detection. If someone presents a refresh token
that verifies (valid signature) but has no live row — because it was already
rotated away — that's a stolen/replayed token, so we revoke every session for
that user. All of this lives in TokenService as plain, fully-unit-tested code
rather than inside a passport strategy (passport internals are hard to branch-test).
OTP: verification and reset
A 6-digit code is generated, argon2-hashed, stored in OtpCode with a short
expiry, and delivered via the NotificationService port (a log adapter in dev, an
SMTP adapter when mail is configured) without touching the auth code.
- Email verification — registration sends a code;
POST /verify-otp(with the access token) checks the newest active code and flipsisVerified. - Password reset —
send-reset-otp→verify-reset-otp(returns a short-lived reset JWT bound to that specific OTP) →reset-password(consumes the OTP exactly once, sets the new password, and revokes all sessions). Splitting "verify" from "reset" with a one-time-use grant is what keeps the flow safe.
Security posture
The foundation shipped through an adversarial pre-merge security review, which shaped several deliberate choices:
- Rate limiting — a global per-IP throttle (
@nestjs/throttler) caps requests, blunting password and OTP brute-force. The limit/window are env-tunable. - Constant-time login — login always performs one argon2 verification (against a dummy hash when the email is unknown), so response time never reveals which accounts exist — backing up the generic 401.
- Strong, separated secrets — access, refresh, and reset tokens use distinct secrets, each required to be ≥32 chars, validated at boot.
- No information leaks — unexpected exceptions become a generic 500 (details are
logged server-side only); password hashes are never serialized (
UserResource).
Why it's testable (the port pattern)
Auth touches three things that are murder for a coverage gate: a database, a clock
(expiry), and crypto/email. The trick: every impure dependency is injected behind
a port — PrismaService, ClockService, RandomService, PasswordService,
NotificationService. Unit tests swap in fakes, so every branch (expired OTP, wrong
code, replayed refresh, wrong user-type, paused account, duplicate email) is driven
deterministically with no real I/O. That's how the suite hits 100% while staying
fast. The e2e suite then proves the whole thing for real against a Postgres test
database, overriding only NotificationService (to read the OTP back); a separate
spec drives the rate limiter to a 429.
Conventions you'll see everywhere
- Base path & casing — every route is served under
/apiand the wire contract is snake_case (last_name,password_confirmation,is_verified, …); internal types stay camelCase, mapped at the edge. The whole surface is published as OpenAPI at/docs, which persists your access token across reloads (persistAuthorization), so you authorize once. See API contract & docs. - Response envelope — a global interceptor wraps success as
{ status: "success", message, data }; a global filter renders errors as{ status: "error", message, errors? }. Validation failures are 422 with errors keyed by field. - Anti-enumeration — the API never reveals which accounts exist: reset always returns 200, login returns a generic 401, bad OTPs a generic 400. The one exception is registration, which tells you exactly which of email/username/phone is taken (you need that to fix the form).
Gotchas & what's next
- Master data is a prerequisite, not done. Registration accepts
country/state/city/gender/ethnicityas plain integers with no foreign-key validation yet. The master-data module (countries/states/cities/…) is the next unit; once it lands, those references get enforced. - Social login shipped for Google + LinkedIn — see the Social login subpage. Apple/Facebook/Microsoft remain deferred; the User model already leaves room for them.
- Hardening follow-ups: per-route tighter throttles, cleanup of expired refresh
rows, and
helmet/CORS + a body-size limit at the HTTP edge. - Running the e2e suite needs a local Postgres and a
.env.test(gitignored — copy.env.example).
See also: API contract & docs, The NestJS scaffold, Testing & coverage, and
the in-repo docs/DECISIONS.md (ADR-0009…0016, ADR-0039) for the "why" behind Prisma, the
token model, argon2id, anti-enumeration, the security hardening, and the API
contract reconciliation.
Subpages