# Implementation Plan: Authentication Hardening **Branch**: `013-auth-hardening` | **Date**: 2026-09-07 | **Spec**: [spec.md](./spec.md) **Input**: Feature specification from `specs/013-auth-hardening/spec.md` ## Summary Adds `POST /auth/password-reset/request` and `POST /auth/password-reset/consume` to `identity/auth` (the module that already owns login/logout/self-identity mechanics), backed by a Redis-stored, single-use reset token — the "delivery" step logs the token server-side rather than emailing it. Adds a shared password-strength validator used by both the reset-consume endpoint and 010's own `POST /admin/users`. Adds a pre-credential-check rate limit to `POST /auth/login`, reusing the existing `checkRateLimit` helper 002's own inbound trust boundary already established. ## Technical Context **Language/Version**: TypeScript 5.4 / Node.js 20+ (unchanged). **Primary Dependencies**: None new — reuses `crypto` (Node built-in, for token generation and hashing), the existing `ioredis` client, and `zod`. **Storage**: No schema change. Reset tokens live entirely in Redis (never in Postgres) — two keys per active token, mirroring the existing revocation-denylist's own Redis-key-with-TTL shape: `password-reset:token:` → `userId`, and `password-reset:user:` → `sha256(token)`, both with the same TTL (the reset token's own lifetime). The second key is what lets issuing a new token invalidate the previous one (FR-002) without a database table. **Testing**: Vitest — unit tests for the password-strength validator and the rate-limit's own pre-credential-check ordering; integration tests against real Postgres/Redis for the full request → (read the token from the stub's log output) → consume → login-with-new-password flow, the identical-response-regardless-of-existing-account behavior, and the login rate limit actually rejecting the N+1th attempt while a different account's login proceeds normally. **Target Platform**: Same Fastify modular monolith. Modifies `identity/auth` (new routes, service methods, the shared password-strength validator) and `identity/agents` (existing `POST /admin/users` now calls the shared validator instead of accepting any password unchecked). **Project Type**: Backend service — single project. **Performance Goals**: The login rate-limit check is one Redis `INCR` (already how `checkRateLimit` works) — no added database round trip on the login hot path, consistent with 010's own performance goal for `fastify.authenticate`. **Constraints**: FR-001/SC-001 — reset-request must respond identically regardless of account existence, including timing-shape (the same pattern 010's login already established: do the same amount of work either way). FR-007 — the rate-limit check MUST run before `bcrypt.compare`, not after i.e. before any password-verification cost is paid, both for FR-007's own ordering requirement and so a rate-limited attacker gains no timing signal from a skipped bcrypt call. **Scale/Scope**: Two new routes, one new shared validator, one new env-configured rate-limit policy, one modified existing endpoint (`POST /admin/users`). No new module, no schema migration, no new module dependencies. Explicitly excludes: MFA, real email delivery, IP-based rate limiting, password complexity rules beyond minimum length (spec.md Assumptions). ## Constitution Check *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* | Principle / Section | Check | Result | |---|---|---| | I. SaaS Is the Sole Identity & Access Authority | Same carve-out as 010 — this hardens SupportHub's own staff authentication, never touching SaaS-delegated customer identity. | PASS | | II. Configuration Over Hardcoding | Password minimum length and the login rate-limit's max-attempts/window are both new env-configured values (`PASSWORD_MIN_LENGTH`, `LOGIN_RATE_LIMIT_MAX_ATTEMPTS`, `LOGIN_RATE_LIMIT_WINDOW_SECONDS`), never hardcoded magic numbers — matches spec.md's own Assumptions and the roadmap's "never hardcode a placeholder value and ship it as final." | PASS | | III. Layered Architecture With Enforced Module Boundaries | Reset endpoints live in `identity/auth` (owns auth mechanics); the shared password-strength validator is exported from `identity/auth`'s own public `index.ts` for `identity/agents` to consume, the same precedent `hashPassword`/`verifyPassword` themselves already set. | PASS | | IV. AI Recommends, Deterministic Policy Decides | Not applicable. | PASS — N/A | | V. Evidence-Based Verification | Not applicable. | PASS — N/A | | VI. Durable Audit & History | Not applicable — no new audit-relevant mutable domain state (a password hash change isn't itself an audited business event in this codebase's existing model). | PASS — N/A | | VII. Concurrency-Safe, Durable Job Handling | Reset-token issuance/consumption is a single Redis operation per step, no shared in-memory state; two concurrent consume attempts for the same token race safely (Redis `GET`+`DEL` — the loser sees the key already gone and is rejected, not a partial/double-apply). | PASS | | VIII. Problem and Ticket Are Separate, Related Entities | Not applicable. | PASS — N/A | | Technology & Platform Constraints | No new dependencies or infrastructure — email delivery is explicitly stubbed (spec.md Assumptions, user decision), not a real provider integration. | PASS | No violations requiring Complexity Tracking justification. ## Project Structure ### Documentation (this feature) ```text specs/013-auth-hardening/ ├── plan.md ├── research.md ├── data-model.md ├── quickstart.md ├── contracts/ └── tasks.md ``` ### Source Code (repository root) ```text supporthub-api/ ├── src/ │ ├── config/ │ │ └── auth.ts # MODIFIED — passwordMinLength, loginRateLimit config │ └── modules/ │ └── identity/ │ ├── auth/ # MODIFIED │ │ ├── mapper/ │ │ │ └── password-policy.ts # NEW — shared validatePasswordStrength │ │ ├── mapper/ │ │ │ └── reset-token.ts # NEW — generate/hash reset tokens │ │ ├── repository/ │ │ │ └── reset-token.repository.ts # NEW — the two-Redis-key shape │ │ ├── service/ # MODIFIED — requestPasswordReset, resetPassword, │ │ │ login's new pre-check rate-limit call │ │ ├── controller/ routes/ # MODIFIED — the two new routes │ │ └── schema/ # MODIFIED — request/consume body schemas │ └── agents/ │ └── service/ │ └── users.service.ts # MODIFIED — calls the shared validator └── tests/ ├── unit/identity/ # password-policy validator, rate-limit ordering └── integration/ # full reset flow, identical-response check, login rate-limit behavior ``` **Structure Decision**: Single project, no new module. Everything lives in `identity/auth` (already owns login/logout/self-identity) except the one-line call site change in `identity/agents/service/users.service.ts`. ## Complexity Tracking *No constitution violations — table intentionally omitted.*