Files
support_backend/specs/013-auth-hardening/plan.md
T
saqib mirandClaude Sonnet 5 52f1fa3db0 docs(013-auth-hardening): plan, research, data model, contract, quickstart
Reset tokens live only in Redis as a paired key shape (mirrors 010's own
revocation-denylist pattern) - never in Postgres, never storing the raw
token. Password-strength policy is one shared validator called from both
the new reset-consume endpoint and 010's existing POST /admin/users.
Login rate-limiting reuses the existing checkRateLimit helper from
002's own inbound trust boundary, keyed by submitted email, checked
before any credential verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:26:49 +05:30

7.4 KiB

Implementation Plan: Authentication Hardening

Branch: 013-auth-hardening | Date: 2026-09-07 | Spec: 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:<sha256(token)>userId, and password-reset:user:<userId>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)

specs/013-auth-hardening/
├── plan.md
├── research.md
├── data-model.md
├── quickstart.md
├── contracts/
└── tasks.md

Source Code (repository root)

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.