Module 1 — Auth backend
Status: backend complete (register, verify, login, logout, refresh rotation, forgot/reset/change password, Google/GitHub OAuth, TOTP MFA + backup codes, RBAC foundation, session management, audit logging, rate limiting, Swagger, seed data — all implemented, tested, and verified live against a real database). Dashboard shell (Next.js frontend) not started.
See docs/adr/0001-auth-module.md for the full list of architectural
decisions and why each one was made.
Scope
Email registration/login, Google/GitHub OAuth, email verification, password
reset/change, TOTP MFA, JWT access + refresh tokens with rotation, and
session/device management. See PROJECT_SPEC.md → Authentication.
Alongside auth itself, this sprint also built a platform foundation used by every future module: full RBAC (Role/Permission/UserRole/RolePermission), per-user Organizations (a personal workspace is created at registration), an ApiKey table (schema only, no endpoints yet), UserPreferences, and three swappable provider interfaces (Mail/Storage/Queue) with dev-local implementations. See decision 8 in the ADR for exactly what's wired vs. schema-only.
Architecture
Ports-and-adapters throughout apps/api/src/modules/*: every aggregate
has a domain/repositories/*.interface.ts (a plain TypeScript interface +
a Symbol DI token) and a infrastructure/persistence/prisma/prisma-*.ts
implementation. Use-cases (modules/auth/use-cases/*.use-case.ts) depend
only on the interfaces — PrismaService/PrismaClient is imported
nowhere outside the Prisma*Repository adapter files themselves.
Controllers stay thin: DTO in, use-case call, DTO out.
Cross-repository transactions (e.g. register writes Users + Rbac +
Organizations + UserPreferences atomically) use @Transactional() from
@nestjs-cls/transactional, backed by a Prisma adapter parameterized with
this project's actual generated client type (see
apps/api/src/prisma/prisma-transaction.types.ts — the adapter's default
type parameter is @prisma/client's standard client, not ours, which
silently produced an untyped transaction object until fixed).
Data model
Defined in apps/api/prisma/schema.prisma, Postgres via Prisma 7 (using
the @prisma/adapter-pg driver adapter — Prisma 7's client no longer reads
DATABASE_URL from the schema file directly).
- User — core account.
passwordHashis nullable so OAuth-only accounts (no password set) are representable.status(ACTIVE/SUSPENDED/DELETED) is separate from session revocation — suspending a user doesn't require walking every session row. - OAuthAccount — one row per linked provider identity, unique on
(provider, providerUserId). A user can link multiple providers to one account. - OAuthExchangeCode — one-time code handed to the frontend after an OAuth
callback; exchanged via
POST /auth/oauth/exchangefor real tokens, so access/refresh tokens never appear in a redirect URL. - Session — a device-level record (what the user sees in "manage devices").
Distinct from
RefreshTokenon purpose: a session survives token rotation; revoking a session cascades to its refresh tokens. Carriesbrowser/os(parsed from the User-Agent viaua-parser-js) and an unpopulatedlocationcolumn (no geo-IP provider configured in this environment). - RefreshToken — always stored hashed (
tokenHash), never plaintext. Rotated on every refresh (replacedByTokenIdlinks the chain) so reuse of a stale token is detectable and treated as a compromise signal. - MfaFactor / MfaBackupCode / MfaChallengeToken — one TOTP
factor per user;
enabledflips true only after a confirming code, so a half-configured factor never gates login. Backup codes are individually hashed rows (sha256, not argon2 — see ADR) so each is independently single-use. - EmailVerificationToken / PasswordResetToken — short-lived, hashed,
single-use (
usedAt) tokens. - Role / Permission / RolePermission / UserRole — RBAC foundation.
Register assigns the default
USERrole; the access token'srolesclaim is resolved from these tables at login/refresh time. - Organization / OrganizationMember — a personal workspace is created
for every new user at registration (
OWNERmembership). No invites/ switching/multi-member flows yet. - ApiKey, UserPreferences — schema-only (ApiKey) or create-default-at-registration (UserPreferences); no dedicated endpoints.
Why hash tokens at rest: if the database leaks, stored hashes are useless to an attacker without also breaking the hash — the same reasoning as password storage, applied to bearer credentials.
API contracts
Defined in packages/shared/src/auth.ts (DTOs), auth-endpoints.ts (route
paths), and api-response.ts (the {success:true,data} /
{statusCode,code,message} envelope every response uses). Both apps/api
and apps/web import from @pentesthub/shared instead of redeclaring
shapes.
Key decisions (full rationale in the ADR):
POST /auth/loginreturns eitherAuthTokensDtoorMfaRequiredResponseDto(discriminated bymfaRequired: true).- Refresh tokens are opaque strings in the request body, not cookies.
AuthErrorCodeis a closed string union the frontend can branch on.- Every response is wrapped: success as
{ success: true, data }, errors as{ statusCode, code, message }— via a global interceptor/filter, not per-controller boilerplate. - No global
/apipath prefix — routes matchauth-endpoints.tsexactly.
Running it locally
No system Postgres or Docker is assumed — Prisma 7 ships a bundled local dev server (pglite/WASM-backed):
cd apps/api
npx prisma dev --detach # prints a connection string
# put it in apps/api/.env as DATABASE_URL (see .env.example for the full var list)
npx prisma db push # NOT `migrate dev` — see note below
npx prisma db seed # creates RBAC roles + an admin/user dev account each
pnpm run start:dev # or: pnpm run build && pnpm run start
db push, not migrate dev: the pglite-backed local server doesn't
support migrate dev's shadow-database flow (a real Postgres target
should use normal migrate dev/migrate deploy with full migration
history — this project doesn't have a prisma/migrations directory yet
for that reason).
Swagger UI is at http://localhost:3000/api/docs once running. Dev emails
(verification/reset links) are written to apps/api/.mail-outbox/*.txt
instead of actually sending, since no SMTP provider is configured.
Testing
Three separate pipelines, all under apps/api:
pnpm test— 61 unit tests across all 18 use-cases, repository interfaces mocked directly (no DB, no network).pnpm test:integration— repository adapter tests against the real dev Postgres (--runInBand: the pglite backend corrupts prepared-statement state under Jest's default parallel workers — a backend limitation, not a product bug).pnpm test:e2e— full HTTP flows through the real app (register, MFA lifecycle, login lockout, rate limiting, refresh reuse detection) against the same dev Postgres.
All three (plus build/lint/check-types) must be green before a
commit, per the project's verification standard.