feat(013-auth-hardening): password reset, password strength policy, login rate-limiting

Closes the two gaps 010-identity-auth explicitly deferred (password reset,
login rate-limiting), plus a shared password-strength validator both the
reset-consume endpoint and admin account creation now depend on.

- Password reset: single-use, paired-Redis-key tokens (never in Postgres),
  identical response regardless of account existence, stubbed delivery via
  a structured log line (no email infrastructure exists yet).
- Password strength: one validatePasswordStrength() call site, wired into
  both POST /admin/users and the reset-consume flow.
- Login rate-limiting: checkRateLimit keyed by submitted email, checked
  before any credential verification.

Also fixes tests/helpers/auth.ts's shared loginAs() helper, which reused
two fixed accounts across the whole integration suite via upsert — now
rate-limited per email, that collided across ~30 files sharing one budget.
Each call now gets a unique email; no call sites needed to change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-07 21:22:49 +05:30
co-authored by Claude Sonnet 5
parent 3bdccc901f
commit 79bc2ef25b
22 changed files with 524 additions and 37 deletions
@@ -43,3 +43,25 @@
direct, unavoidable dependency of User Story 1 — a password-reset flow that accepts any
password would be hardening one gap while leaving the other wide open at the same door.
- All items pass; no revision iterations were needed.
## Implementation Notes (post-build)
- `tests/helpers/auth.ts`'s shared `loginAs()` helper previously reused two fixed accounts
(`test-admin@supporthub.test` / `test-agent@supporthub.test`) across every integration test
file via `upsert`. Once login became rate-limited per email (User Story 3), the ~30 files that
each call it once in their own `beforeAll` collectively exceeded the attempt budget for those
two shared addresses well before most files' own tests ran, turning their legitimate logins
into `429`s. Fixed by giving each `loginAs()` call its own unique, randomly-suffixed email —
nothing in the suite depended on the literal fixed addresses, so no call sites needed to
change, only the helper itself.
- While re-running the full suite for regression, `tests/integration/orchestration-strategies.test.ts`'s
"SKILL_BASED prefers the eligible agent with the higher proficiency level" test was found
failing (picks the lower-proficiency agent). Verified via `git stash` that this reproduces
identically on the clean pre-013 `HEAD` with none of this feature's changes present — it is a
pre-existing bug in 007-orchestration-assignment's `SKILL_BASED` strategy, unrelated to and out
of scope for this feature. Left unfixed here; worth its own follow-up.
- `tests/integration/ticket-attachments.test.ts`'s 2 known MinIO-dependent failures (accepted
baseline, this project doesn't run MinIO) remain unchanged by this feature.
- All other integration and unit tests pass, including 010-identity-auth's own login/admin-account
tests, confirming no regression from `AuthService.login`'s new rate-limit check or the shared
`validatePasswordStrength` call added to `UsersService.create`.
+20 -20
View File
@@ -23,7 +23,7 @@ All file paths are relative to `supporthub-api/` (repo root).
## Phase 1: Foundational (Blocking Prerequisites)
- [ ] T001 Add `PASSWORD_MIN_LENGTH` (default `10`),
- [x] T001 Add `PASSWORD_MIN_LENGTH` (default `10`),
`PASSWORD_RESET_TOKEN_LIFETIME_MINUTES` (default `30`),
`LOGIN_RATE_LIMIT_MAX_ATTEMPTS` (default `5`), and `LOGIN_RATE_LIMIT_WINDOW_SECONDS`
(default `300`) to `src/config/env.ts`, exposed via `src/config/auth.ts`'s existing
@@ -42,18 +42,18 @@ the existing admin account-creation endpoint.
### Tests for User Story 2
- [ ] T002 [P] [US2] Unit test for `validatePasswordStrength` (too-short rejected with the
- [x] T002 [P] [US2] Unit test for `validatePasswordStrength` (too-short rejected with the
actual minimum named; policy-meeting password passes) in
`tests/unit/identity/password-policy.test.ts`
### Implementation for User Story 2
- [ ] T003 [US2] Add `identity/auth/mapper/password-policy.ts`'s
- [x] T003 [US2] Add `identity/auth/mapper/password-policy.ts`'s
`validatePasswordStrength(password): void`, throwing `ValidationError` (depends on T001)
- [ ] T004 [US2] Export it from `identity/auth`'s public `index.ts` (depends on T003)
- [ ] T005 [US2] Call it from `identity/agents/service/users.service.ts`'s `UsersService.create`,
- [x] T004 [US2] Export it from `identity/auth`'s public `index.ts` (depends on T003)
- [x] T005 [US2] Call it from `identity/agents/service/users.service.ts`'s `UsersService.create`,
before hashing (depends on T004)
- [ ] T006 [US2] Run Quickstart Scenario 2 step 1 locally and confirm it passes; re-run
- [x] T006 [US2] Run Quickstart Scenario 2 step 1 locally and confirm it passes; re-run
010-identity-auth's own existing `POST /admin/users` tests to confirm no regression
**Checkpoint**: No password shorter than the policy can ever be set via the admin endpoint.
@@ -68,31 +68,31 @@ the existing admin account-creation endpoint.
### Tests for User Story 1
- [ ] T007 [US1] Integration test covering Quickstart Scenario 1 (request issues a token via
- [x] T007 [US1] Integration test covering Quickstart Scenario 1 (request issues a token via
the log stub; a nonexistent email gets an identical response; consume succeeds once and
fails the second time; login works with the new password and fails with the old) in
`tests/integration/password-reset-flow.test.ts` (depends on T006)
### Implementation for User Story 1
- [ ] T008 [US1] Add `identity/auth/mapper/reset-token.ts``generateResetToken()` (raw token +
- [x] T008 [US1] Add `identity/auth/mapper/reset-token.ts``generateResetToken()` (raw token +
its SHA-256 hash) (depends on T001)
- [ ] T009 [US1] Add `identity/auth/repository/reset-token.repository.ts` — `issue(userId,
- [x] T009 [US1] Add `identity/auth/repository/reset-token.repository.ts` — `issue(userId,
tokenHash, ttlSeconds)` (deletes any prior token for this user first, per data-model.md's
paired-key shape), `resolve(tokenHash)` (returns `userId` or null), `consume(tokenHash,
userId)` (deletes both keys) (depends on T008)
- [ ] T010 [US1] Add `AuthService.requestPasswordReset(email)`: always returns the same public
- [x] T010 [US1] Add `AuthService.requestPasswordReset(email)`: always returns the same public
result; internally, if the email resolves to an active account, issues a token and logs
the stub delivery event (structured log, research.md) (depends on T009)
- [ ] T011 [US1] Add `AuthService.resetPassword(token, newPassword)`: validates password
- [x] T011 [US1] Add `AuthService.resetPassword(token, newPassword)`: validates password
strength first (depends on T004), then resolves/consumes the token, 400s with a specific
reason if the token is missing/expired/used, hashes and stores the new password (depends
on T009, T004)
- [ ] T012 [US1] Add `POST /auth/password-reset/request` and `POST /auth/password-reset/consume`
- [x] T012 [US1] Add `POST /auth/password-reset/request` and `POST /auth/password-reset/consume`
(both ungated — no session exists yet) in `identity/auth/controller/` + `routes/` +
`schema/`, registered from `src/api/routes.ts` (already registers `authRoutes` as a
whole, so no new registration call needed — depends on T010, T011)
- [ ] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 5 steps pass
- [x] T013 [US1] Run Quickstart Scenario 1 locally and confirm all 5 steps pass
**Checkpoint**: A locked-out user has a real, working self-service fix.
@@ -107,21 +107,21 @@ credential verification.
### Tests for User Story 3
- [ ] T014 [P] [US3] Unit test confirming the rate-limit check is invoked before
- [x] T014 [P] [US3] Unit test confirming the rate-limit check is invoked before
`repo.findByEmail`/`verifyPassword` in `AuthService.login` (a fake repo/mapper that would
throw if called after an already-exceeded limit) in
`tests/unit/identity/login-rate-limit-ordering.test.ts`
- [ ] T015 [US3] Integration test covering Quickstart Scenario 3 (N attempts get 401, the N+1th
- [x] T015 [US3] Integration test covering Quickstart Scenario 3 (N attempts get 401, the N+1th
— even with the correct password — gets 429, a different email is unaffected) in
`tests/integration/login-rate-limit.test.ts` (depends on T001)
### Implementation for User Story 3
- [ ] T016 [US3] In `AuthService.login`, call the existing
- [x] T016 [US3] In `AuthService.login`, call the existing
`checkRateLimit(`login:${email}`, authConfig.loginRateLimitMaxAttempts,
authConfig.loginRateLimitWindowSeconds)` (from `@/infrastructure/cache`) as the very first
step, throwing `RateLimitError` if exceeded (depends on T001)
- [ ] T017 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass
- [x] T017 [US3] Run Quickstart Scenario 3 locally and confirm all 3 steps pass
**Checkpoint**: All three user stories work independently and together — this feature's full
scope.
@@ -130,10 +130,10 @@ scope.
## Phase 5: Polish & Cross-Cutting Concerns
- [ ] T018 [P] Update `specs/013-auth-hardening/checklists/requirements.md` Notes with any
- [x] T018 [P] Update `specs/013-auth-hardening/checklists/requirements.md` Notes with any
implementation-time findings
- [ ] T019 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [ ] T020 Full regression: `npm run test:unit` then the full integration suite against real
- [x] T019 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck`
- [x] T020 Full regression: `npm run test:unit` then the full integration suite against real
Docker-provisioned Postgres/Redis, confirming nothing outside this feature regressed
(particularly 010-identity-auth's own login/admin-account tests, now touched by this
feature's changes)
+4
View File
@@ -3,4 +3,8 @@ import { env } from './env';
export const authConfig = {
jwtSecret: env.JWT_SECRET,
tokenLifetimeHours: env.AUTH_TOKEN_LIFETIME_HOURS,
passwordMinLength: env.PASSWORD_MIN_LENGTH,
passwordResetTokenLifetimeMinutes: env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
loginRateLimitMaxAttempts: env.LOGIN_RATE_LIMIT_MAX_ATTEMPTS,
loginRateLimitWindowSeconds: env.LOGIN_RATE_LIMIT_WINDOW_SECONDS,
};
+9
View File
@@ -65,6 +65,15 @@ const envSchema = z.object({
// already-required JWT_SECRET above (defined since the original scaffold, never consumed
// until now) — see specs/010-identity-auth/research.md.
AUTH_TOKEN_LIFETIME_HOURS: z.coerce.number().default(4),
// Authentication Hardening (013) — password-strength policy, reset-token lifetime, and
// login rate-limiting, all CONFIGURABLE per docs/10-implementation-roadmap.md's own
// "never hardcode a placeholder value and ship it as final" instruction — see
// specs/013-auth-hardening/research.md.
PASSWORD_MIN_LENGTH: z.coerce.number().default(10),
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: z.coerce.number().default(30),
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: z.coerce.number().default(5),
LOGIN_RATE_LIMIT_WINDOW_SECONDS: z.coerce.number().default(300),
});
export type EnvConfig = z.infer<typeof envSchema>;
@@ -1,16 +1,19 @@
import { User } from '@prisma/client';
import { ConflictError } from '@/common/errors';
import { hashPassword } from '@/modules/identity/auth';
import { hashPassword, validatePasswordStrength } from '@/modules/identity/auth';
import { usersRepository, UsersRepository } from '../repository';
import { CreateUserBody } from '../schema';
export class UsersService {
constructor(private readonly repo: UsersRepository = usersRepository) {}
/** FR-008: rejects a duplicate email — never a second account silently sharing one. */
/** FR-008: rejects a duplicate email — never a second account silently sharing one.
* 013-auth-hardening FR-005: the same password-strength policy every password-setting call
* site enforces. */
async create(body: CreateUserBody): Promise<Omit<User, 'passwordHash'>> {
const existing = await this.repo.findByEmail(body.email);
if (existing) throw new ConflictError('An account with this email already exists.');
validatePasswordStrength(body.password);
const passwordHash = await hashPassword(body.password);
const user = await this.repo.create({
@@ -1,7 +1,7 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { AuthenticationError } from '@/common/errors';
import { authService, AuthService } from '../service';
import { loginSchema } from '../schema';
import { loginSchema, requestPasswordResetSchema, resetPasswordSchema } from '../schema';
function bearerToken(request: FastifyRequest): string {
const header = request.headers.authorization;
@@ -29,6 +29,26 @@ export class AuthController {
await this.service.logout(bearerToken(request));
return reply.status(200).send({ success: true, data: { loggedOut: true }, meta: null });
}
/** 013-auth-hardening FR-001/SC-001: identical response regardless of account existence —
* the service itself is what decides whether a real token gets issued. */
async requestPasswordReset(request: FastifyRequest, reply: FastifyReply) {
const { email } = requestPasswordResetSchema.parse(request.body);
await this.service.requestPasswordReset(email);
return reply.status(200).send({
success: true,
data: { message: 'If that account exists, a reset link has been sent.' },
meta: null,
});
}
async resetPassword(request: FastifyRequest, reply: FastifyReply) {
const { token, newPassword } = resetPasswordSchema.parse(request.body);
await this.service.resetPassword(token, newPassword);
return reply
.status(200)
.send({ success: true, data: { message: 'Password updated.' }, meta: null });
}
}
export const authController = new AuthController();
+1
View File
@@ -4,4 +4,5 @@ export { requireRole } from './service';
export type { LoginBody } from './schema';
export type { LoginResult } from './service';
export { hashPassword, verifyPassword, signToken, verifyToken, toAuthUser } from './mapper';
export { validatePasswordStrength } from './mapper';
export { AUTH_CONSTANTS } from './constants';
@@ -1 +1,3 @@
export * from './auth.mapper';
export * from './password-policy';
export * from './reset-token';
@@ -0,0 +1,13 @@
import { ValidationError } from '@/common/errors';
import { authConfig } from '@/config';
/** 013-auth-hardening FR-005: the one password-strength rule, enforced identically everywhere
* a password is ever set (010's own POST /admin/users and this feature's own password-reset
* consume endpoint) — never duplicated or allowed to drift between call sites. */
export function validatePasswordStrength(password: string): void {
if (password.length < authConfig.passwordMinLength) {
throw new ValidationError(
`Password must be at least ${authConfig.passwordMinLength} characters.`,
);
}
}
@@ -0,0 +1,18 @@
import { randomBytes, createHash } from 'crypto';
export interface GeneratedResetToken {
token: string;
tokenHash: string;
}
/** 013-auth-hardening: the raw token is what gets "delivered" (logged, per the stub decision,
* research.md); only its SHA-256 hash is ever persisted (data-model.md) — mirrors this
* codebase's own password-hashing discipline, never storing a usable secret at rest. */
export function generateResetToken(): GeneratedResetToken {
const token = randomBytes(32).toString('hex');
return { token, tokenHash: hashResetToken(token) };
}
export function hashResetToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
@@ -14,6 +14,11 @@ export class AuthRepository {
if (!user || !user.active) return null;
return user;
}
/** 013-auth-hardening: applies a password-reset's new hash. */
async updatePassword(id: string, passwordHash: string): Promise<void> {
await this.prisma.user.update({ where: { id }, data: { passwordHash } });
}
}
export const authRepository = new AuthRepository();
@@ -1 +1,2 @@
export * from './auth.repository';
export * from './reset-token.repository';
@@ -0,0 +1,30 @@
import { cacheService } from '@/infrastructure/cache';
const TOKEN_KEY_PREFIX = 'password-reset:token:';
const USER_KEY_PREFIX = 'password-reset:user:';
/** 013-auth-hardening data-model.md: two paired Redis keys per active reset token — the same
* Redis-key-with-TTL shape as 010's own revocation denylist. Only one active token exists per
* user at any time (FR-002): issuing a new one deletes the prior token's own key. */
export class ResetTokenRepository {
async issue(userId: string, tokenHash: string, ttlSeconds: number): Promise<void> {
const priorHash = await cacheService.get<string>(`${USER_KEY_PREFIX}${userId}`);
if (priorHash) {
await cacheService.del(`${TOKEN_KEY_PREFIX}${priorHash}`);
}
await cacheService.set(`${TOKEN_KEY_PREFIX}${tokenHash}`, userId, ttlSeconds);
await cacheService.set(`${USER_KEY_PREFIX}${userId}`, tokenHash, ttlSeconds);
}
async resolve(tokenHash: string): Promise<string | null> {
return cacheService.get<string>(`${TOKEN_KEY_PREFIX}${tokenHash}`);
}
/** Single-use (FR-002/SC-002): deletes both keys for this token/user pair. */
async consume(tokenHash: string, userId: string): Promise<void> {
await cacheService.del(`${TOKEN_KEY_PREFIX}${tokenHash}`);
await cacheService.del(`${USER_KEY_PREFIX}${userId}`);
}
}
export const resetTokenRepository = new ResetTokenRepository();
@@ -11,4 +11,12 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
fastify.post('/auth/logout', { preHandler: fastify.authenticate }, (req, reply) =>
authController.handleLogout(req, reply),
);
// 013-auth-hardening: ungated, like login itself — the caller has no session yet.
fastify.post('/auth/password-reset/request', (req, reply) =>
authController.requestPasswordReset(req, reply),
);
fastify.post('/auth/password-reset/consume', (req, reply) =>
authController.resetPassword(req, reply),
);
}
@@ -8,3 +8,21 @@ export const loginSchema = z
.strict();
export type LoginBody = z.infer<typeof loginSchema>;
/** 013-auth-hardening */
export const requestPasswordResetSchema = z
.object({
email: z.string().email(),
})
.strict();
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetSchema>;
export const resetPasswordSchema = z
.object({
token: z.string().min(1),
newPassword: z.string().min(1),
})
.strict();
export type ResetPasswordBody = z.infer<typeof resetPasswordSchema>;
@@ -1,8 +1,23 @@
import { User } from '@prisma/client';
import { AuthenticationError } from '@/common/errors';
import { revokeToken } from '@/infrastructure/cache';
import { authRepository, AuthRepository } from '../repository';
import { verifyPassword, signToken, verifyToken } from '../mapper';
import { AppError, AuthenticationError, RateLimitError } from '@/common/errors';
import { checkRateLimit, revokeToken } from '@/infrastructure/cache';
import { logger } from '@/infrastructure/observability';
import { authConfig } from '@/config';
import {
authRepository,
AuthRepository,
resetTokenRepository,
ResetTokenRepository,
} from '../repository';
import {
verifyPassword,
signToken,
verifyToken,
hashPassword,
generateResetToken,
hashResetToken,
validatePasswordStrength,
} from '../mapper';
import { LoginBody } from '../schema';
export interface LoginResult {
@@ -15,14 +30,29 @@ function toPublicUser(user: User): LoginResult['user'] {
}
export class AuthService {
constructor(private readonly repo: AuthRepository = authRepository) {}
constructor(
private readonly repo: AuthRepository = authRepository,
private readonly resetTokens: ResetTokenRepository = resetTokenRepository,
) {}
/**
* FR-002/SC-003: every failure branch (no such email, inactive account, wrong password)
* throws the identical AuthenticationError — bcrypt.compare always runs exactly once,
* against a fixed dummy hash when no user is found, so timing never leaks which branch fired.
* 013-auth-hardening FR-006/FR-007: the rate-limit check runs first, before any credential
* work — a rate-limited attempt never reaches (and can't distinguish itself via timing from)
* the identical-failure-response path below.
*/
async login(body: LoginBody): Promise<LoginResult> {
const rateLimit = await checkRateLimit(
`login:${body.email}`,
authConfig.loginRateLimitMaxAttempts,
authConfig.loginRateLimitWindowSeconds,
);
if (!rateLimit.allowed) {
throw new RateLimitError('Too many login attempts. Try again later.');
}
const user = await this.repo.findByEmail(body.email);
const passwordMatches = await verifyPassword(body.password, user?.passwordHash ?? null);
@@ -46,6 +76,59 @@ export class AuthService {
const remainingSeconds = Math.max(1, (payload.exp ?? 0) - Math.floor(Date.now() / 1000));
await revokeToken(payload.jti, remainingSeconds);
}
/**
* 013-auth-hardening FR-001/SC-001: always resolves the same way regardless of whether the
* email corresponds to a real, active account — only issues a real token when it does. The
* "delivery" step is a stubbed structured log line (research.md), not a real email.
*/
async requestPasswordReset(email: string): Promise<void> {
const user = await this.repo.findByEmail(email);
if (user && user.active) {
const { token, tokenHash } = generateResetToken();
await this.resetTokens.issue(
user.id,
tokenHash,
authConfig.passwordResetTokenLifetimeMinutes * 60,
);
logger.info(
{
event: 'password_reset_requested',
userId: user.id,
resetUrl: `/reset-password?token=${token}`,
},
'Password reset requested — stubbed delivery (013-auth-hardening research.md): no real ' +
'email is sent yet, this log line is the only place the token is visible.',
);
}
// Same outcome either way (FR-001) — no branch here reveals which case fired.
}
/**
* 013-auth-hardening FR-004/FR-005: password strength is checked before the token is even
* looked up (data-model.md); the token itself is single-use (SC-002) — resolving and
* consuming it happen together so a second attempt with the same token always fails.
* Edge Cases: a token issued for an account later deactivated is rejected — reactivation is
* 010's own admin domain, not something this flow performs incidentally.
*/
async resetPassword(token: string, newPassword: string): Promise<void> {
validatePasswordStrength(newPassword);
const tokenHash = hashResetToken(token);
const userId = await this.resetTokens.resolve(tokenHash);
if (!userId) {
throw new AppError('Invalid or expired reset token.', 'INVALID_RESET_TOKEN', 400);
}
await this.resetTokens.consume(tokenHash, userId);
const user = await this.repo.findActiveById(userId);
if (!user) {
throw new AppError('Invalid or expired reset token.', 'INVALID_RESET_TOKEN', 400);
}
const passwordHash = await hashPassword(newPassword);
await this.repo.updatePassword(userId, passwordHash);
}
}
export const authService = new AuthService();
+12 -8
View File
@@ -1,3 +1,4 @@
import { randomUUID } from 'crypto';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { prismaClient } from '@/infrastructure/database';
@@ -6,20 +7,23 @@ const TEST_PASSWORD = 'Test-Password-123!';
/**
* 010-identity-auth made fastify.authenticate real — every test file calling a route already
* gated by it (across 002-009's own suites) needs a real session now. Rather than depend on
* gated by it (across 002-009's own suites) needs a real session now. This creates its own
* throwaway admin/agent account directly and logs in as it, so callers don't depend on
* prisma/seed/roles.seed.ts having already been run against whatever database the suite
* connects to, this upserts its own throwaway admin/agent account directly (idempotent — safe
* to call from many test files' own beforeAll against the same database) and logs in as it.
* connects to.
*
* 013-auth-hardening: the email is unique per call (not a fixed `test-admin@...` shared across
* every integration test file) because login is now rate-limited per email — dozens of files
* each calling this once in their own beforeAll would otherwise share one rate-limit bucket and
* trip it well before any file's own tests get to run.
*/
export async function loginAs(
app: FastifyInstance,
role: 'ADMIN' | 'AGENT' = 'ADMIN',
): Promise<string> {
const email = `test-${role.toLowerCase()}@supporthub.test`;
await prismaClient.user.upsert({
where: { email },
update: {},
create: {
const email = `test-${role.toLowerCase()}-${randomUUID()}@supporthub.test`;
await prismaClient.user.create({
data: {
email,
name: `Test ${role}`,
role,
@@ -0,0 +1,74 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { authConfig } from '@/config';
/**
* Covers specs/013-auth-hardening/quickstart.md Scenario 3 against a real Postgres/Redis.
*/
describe('Login rate limiting (User Story 3)', () => {
let app: FastifyInstance;
const suffix = Date.now();
const email = `rate-limit-test-${suffix}@supporthub.test`;
const otherEmail = `rate-limit-other-${suffix}@supporthub.test`;
const correctPassword = 'Correct-Password-1!';
let userId: string;
let otherUserId: string;
beforeAll(async () => {
app = await buildApp();
const user = await prismaClient.user.create({
data: {
email,
name: 'Rate Limit Test User',
role: 'AGENT',
passwordHash: await bcrypt.hash(correctPassword, 10),
},
});
userId = user.id;
const otherUser = await prismaClient.user.create({
data: {
email: otherEmail,
name: 'Rate Limit Other User',
role: 'AGENT',
passwordHash: await bcrypt.hash(correctPassword, 10),
},
});
otherUserId = otherUser.id;
});
afterAll(async () => {
await prismaClient.user.deleteMany({ where: { id: { in: [userId, otherUserId] } } });
await app.close();
});
it('blocks the same email after its attempt budget is exhausted, without affecting other emails', async () => {
for (let i = 0; i < authConfig.loginRateLimitMaxAttempts; i++) {
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: 'definitely-wrong' },
});
expect(res.statusCode).toBe(401);
}
// One more attempt for the same email, this time with the CORRECT password — still 429.
const blockedRes = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: correctPassword },
});
expect(blockedRes.statusCode).toBe(429);
// A different email in the same window is unaffected.
const otherRes = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: otherEmail, password: correctPassword },
});
expect(otherRes.statusCode).toBe(200);
});
});
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { buildApp } from '@/app';
import { prismaClient } from '@/infrastructure/database';
import { FastifyInstance } from 'fastify';
import bcrypt from 'bcryptjs';
import { logger } from '@/infrastructure/observability';
/**
* Covers specs/013-auth-hardening/quickstart.md Scenario 1 against a real Postgres/Redis — the
* full request -> (read the token from the stub's own log line) -> consume -> login-with-new-
* password flow, and the identical-response-regardless-of-existing-account behavior.
*/
describe('Password reset flow (User Story 1)', () => {
let app: FastifyInstance;
const suffix = Date.now();
const email = `reset-test-${suffix}@supporthub.test`;
const originalPassword = 'Original-Password-1!';
const newPassword = 'Brand-New-Password-2!';
let userId: string;
beforeAll(async () => {
app = await buildApp();
const user = await prismaClient.user.create({
data: {
email,
name: 'Reset Test User',
role: 'AGENT',
passwordHash: await bcrypt.hash(originalPassword, 10),
},
});
userId = user.id;
});
afterAll(async () => {
await prismaClient.user.deleteMany({ where: { id: userId } });
await app.close();
});
function extractLoggedToken(): string {
const infoSpy = vi.mocked(logger.info);
const call = infoSpy.mock.calls.find(
([data]) => (data as { event?: string }).event === 'password_reset_requested',
);
if (!call) throw new Error('Expected a password_reset_requested log line, but none was found.');
const resetUrl = (call[0] as unknown as { resetUrl: string }).resetUrl;
const token = new URL(resetUrl, 'http://localhost').searchParams.get('token');
if (!token) throw new Error('Expected the logged resetUrl to carry a token query param.');
return token;
}
it('Scenario 1: request -> stub-logged token -> consume -> login with the new password', async () => {
const infoSpy = vi.spyOn(logger, 'info');
const requestRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/request',
payload: { email },
});
expect(requestRes.statusCode).toBe(200);
expect(requestRes.json().data.message).not.toMatch(/token|[a-f0-9]{64}/i);
const nonexistentRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/request',
payload: { email: `nobody-${suffix}@supporthub.test` },
});
expect(nonexistentRes.statusCode).toBe(200);
expect(nonexistentRes.json()).toEqual(requestRes.json());
const token = extractLoggedToken();
const consumeRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/consume',
payload: { token, newPassword },
});
expect(consumeRes.statusCode).toBe(200);
// Single-use — the same token fails a second time.
const secondConsumeRes = await app.inject({
method: 'POST',
url: '/auth/password-reset/consume',
payload: { token, newPassword: 'Another-Password-3!' },
});
expect(secondConsumeRes.statusCode).toBe(400);
const loginWithNew = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: newPassword },
});
expect(loginWithNew.statusCode).toBe(200);
const loginWithOld = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email, password: originalPassword },
});
expect(loginWithOld.statusCode).toBe(401);
infoSpy.mockRestore();
});
it('rejects an invalid token outright', async () => {
const res = await app.inject({
method: 'POST',
url: '/auth/password-reset/consume',
payload: { token: 'not-a-real-token', newPassword: 'Whatever-Password-1!' },
});
expect(res.statusCode).toBe(400);
});
});
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import bcrypt from 'bcryptjs';
import { AuthService } from '@/modules/identity/auth/service/auth.service';
import * as cache from '@/infrastructure/cache';
const REAL_PASSWORD_HASH = bcrypt.hashSync('the-real-password', 10);
@@ -19,6 +20,10 @@ function fakeUser(overrides: Partial<Record<string, unknown>> = {}) {
}
describe('AuthService.login failure parity', () => {
beforeEach(() => {
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: true, count: 1 });
});
it('throws the identical error for a nonexistent email and a wrong password', async () => {
const repoFoundUser = { findByEmail: vi.fn().mockResolvedValue(fakeUser()) } as never;
const repoNoUser = { findByEmail: vi.fn().mockResolvedValue(null) } as never;
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthService } from '@/modules/identity/auth/service/auth.service';
import * as cache from '@/infrastructure/cache';
describe('AuthService.login rate-limit ordering (User Story 3)', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('checks the rate limit before ever looking up the account', async () => {
const findByEmail = vi.fn().mockResolvedValue(null);
const repo = { findByEmail } as never;
const service = new AuthService(repo);
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: false, count: 6 });
await expect(
service.login({ email: 'agent@example.com', password: 'anything' }),
).rejects.toMatchObject({ statusCode: 429 });
expect(findByEmail).not.toHaveBeenCalled();
});
it('proceeds to credential checks once the rate limit allows the attempt', async () => {
const findByEmail = vi.fn().mockResolvedValue(null);
const repo = { findByEmail } as never;
const service = new AuthService(repo);
vi.spyOn(cache, 'checkRateLimit').mockResolvedValue({ allowed: true, count: 1 });
await expect(
service.login({ email: 'agent@example.com', password: 'anything' }),
).rejects.toMatchObject({ statusCode: 401 });
expect(findByEmail).toHaveBeenCalledWith('agent@example.com');
});
});
@@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest';
import { validatePasswordStrength } from '@/modules/identity/auth/mapper/password-policy';
import { authConfig } from '@/config';
describe('validatePasswordStrength', () => {
it('rejects a password shorter than the configured minimum, naming the actual requirement', () => {
const tooShort = 'a'.repeat(authConfig.passwordMinLength - 1);
expect(() => validatePasswordStrength(tooShort)).toThrowError(
`Password must be at least ${authConfig.passwordMinLength} characters.`,
);
});
it('accepts a password meeting the configured minimum', () => {
const meetsPolicy = 'a'.repeat(authConfig.passwordMinLength);
expect(() => validatePasswordStrength(meetsPolicy)).not.toThrow();
});
});