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>
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.