CODEX // oaoisme wiki

§ unswayed-backend

Authentication

updated 2026-06-15

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.

passwordHash is nullable and authProvider defaults 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.md ADR-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's type claim, 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 via JWT_ACCESS_TTL.
  • Refresh token — a long-lived JWT (7d) whose sha-256 hash is stored in RefreshToken. On /refresh it 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 flips isVerified.
  • Password resetsend-reset-otpverify-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 portPrismaService, 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 /api and 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/ethnicity as 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