Skip to content

feat(auth): password reset flow with single-use, hashed, expiring tokens - #140

Merged
afeezorobsco-cyber merged 9 commits into
DocForum:mainfrom
Oyinkans0la12:feat/password-reset-flow-with-single-use-hashed
Oct 20, 2026
Merged

afeezorobsco-cyber merged 9 commits into
DocForum:mainfrom
Oyinkans0la12:feat/password-reset-flow-with-single-use-hashed

Conversation

@Oyinkans0la12

Copy link
Copy Markdown
Contributor

What changed

Implements the password reset flow from #33: POST /auth/password-reset/request
and POST /auth/password-reset/confirm, plus the storage and revocation
plumbing that makes it safe to expose.

Closes #33

Why

Auth had signup/login/refresh/logout only — a locked-out patient could not
reach their care thread and a doctor could not reach their queue. A reset
endpoint is also one of the most attacked surfaces in any app, so it is built
around the constraints the issue lists rather than bolted on.

Implementation notes

  • No account-existence signal. /request returns 202 with an identical
    body for a known and an unknown email, and does the same shape of work
    (hashes a throwaway token) so timing doesn't leak either. The only
    intentional difference is 429 from rate limiting, which fires before any
    account lookup.
  • Only the hash is stored. The raw token (32 random bytes, base64url)
    exists in memory and in the outbound notification only.
    PasswordResetToken.tokenHash is a unique SHA-256 index; confirm compares
    constant-time with crypto.timingSafeEqual after the indexed lookup.
  • Single-use, expiring, superseding. used_at covers "already used" and
    "superseded": requesting a second reset marks the first token used in the
    same transaction
    that inserts the new one, so at most one token per user is
    ever live. Unknown, expired, reused and superseded tokens all return the
    same generic 400. Applies the smallest-reasonable-default rule to the
    "second request invalidates the first" edge case.
  • Old refresh tokens stop working. Consuming a token sets the new password
    hash and increments a new User.tokenVersion in one transaction. Every
    token carries tv and POST /auth/refresh compares it to the column — so a
    reset kills all outstanding refresh tokens immediately. Assumption, per
    the issue's own fallback:
    [Phase 8] JWT refresh-token revocation #18's revocation list has not landed, so this is
    the per-user token version the issue named, not a per-session store. It
    cannot revoke one device and leave others; noted in ARCHITECTURE.md §6.1.4
    and ARCHITECTURE_ESSENTIALS.md as a remaining gap rather than claimed closed.
  • No provider decision needed. The link is dispatched through the
    notifications module's NotificationChannel interface, with a console/dev
    adapter as the default (and a swap seam tests use). [Phase 6] ADR: pick notification provider (email/SMS) #17 stays open and
    unblocking.

Schema change (proposed, as the issue requires)

  • PasswordResetToken — new table: id, userId, tokenHash (unique), expiresAt, usedAt, createdAt. Documented in ARCHITECTURE.md §4.9.
  • User.tokenVersion Int @default(0) — documented in §4.1.

Migration: backend/prisma/migrations/20261015175723_add_password_reset_tokens/
generated with scripts/gen-migration.mjs (real Postgres diff).

Assumptions / gaps flagged, not silently resolved

  • The rate limiter is in-process memory (auth/services/rate-limit.ts) —
    correct for the current single-instance preview, wrong across replicas
    (needs a shared store). Listed in ARCHITECTURE_ESSENTIALS.md known gaps.
  • A bumped-away access token still works for the remainder of its 15-minute
    TTL; there is no per-request revocation check.
  • No Notification DB row is written for outbound messages — that model is
    still the Phase 0 placeholder without userId, and the in-app feed is Phase
    6. Persisting one here would have meant inventing schema.

Docs updated in the same PR

ARCHITECTURE.md (§4.1, §4.9, §6.1.4), ARCHITECTURE_ESSENTIALS.md
(data model map + known gaps), docs/api/README.md (both endpoints and the
tv claim), ROADMAP.md (Phase 8 item + changelog line).

Tests

npm run test → 24 passed. npm run test:integration → 9 passed (1 existing
booking + 8 new). npm run typecheck and npm run build clean.

New integration test (tests/integration/password-reset.test.mts, real
Postgres via the existing embedded-Postgres pattern) covers:

  • unknown email returns the same status and body as a known one, and sends no
    notification
  • only the SHA-256 hash is persisted — the raw token is not in the DB
  • reset works, the old password stops working, and the token cannot be reused
    (same generic error)
  • a second request supersedes the first (first token rejected, second works)
  • expired and never-issued tokens rejected with the same generic error
  • a refresh cookie issued before the reset is rejected afterwards (401)
  • per-email rate limit returns 429 after 3 requests
Test Files  6 passed (6)
     Tests  33 passed (33)   # npm run test:all, excluding the pre-existing
                            # tests/testnet-integration suite (needs the live
                            # testnet DB; not part of CI)

Note: npm run test:all also runs tests/testnet-integration, which requires
DATABASE_URL_TEST and testnet access and fails without them — pre-existing,
not part of ci.yml (which runs test + test:integration).

Adds POST /auth/password-reset/request (always 202, no account-existence
signal, rate-limited per email and per IP) and
POST /auth/password-reset/confirm. Only a SHA-256 hash of the token is
stored, with an expiry and a used_at marker; a second request supersedes
the first in the same transaction, so at most one token is ever live.

A successful reset rotates the password and bumps a new User.tokenVersion,
which is carried as `tv` in every access/refresh token and checked at
refresh — every outstanding refresh token for that user dies immediately.
That is the interim revocation mechanism the issue named for the case
where DocForum#18 has not landed.

Link delivery goes through the notifications module's provider-agnostic
NotificationChannel (console adapter by default), so no provider decision
(DocForum#17) was needed to ship this.

Docs updated in the same change: ARCHITECTURE.md §4.1/§4.9/§6.1.4,
ARCHITECTURE_ESSENTIALS.md, docs/api/README.md, ROADMAP.md.

Refs DocForum#33
@drips-wave

drips-wave Bot commented Oct 15, 2026

Copy link
Copy Markdown

@Oyinkans0la12 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oyinkans0la12 pls fix conflict

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

CI is failing on this PR. main itself is green, so the cause is in this branch:

prisma:generate fails: Type "PasswordResetToken" is neither a built-in type, nor refers to another model. The model PasswordResetToken block from your commit 4fd85b6 was dropped from backend/prisma/schema.prisma when you merged main in (64b1090). The relation field that uses it is still there. Re-add the model block.

Please push a fix and CI will re-run. Before pushing, run npm run prisma:generate && npm run typecheck && npm test && npm run test:integration in backend/.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oyinkans0la12

CI fails at prisma generate (P1012): Type "PasswordResetToken" is neither a built-in type, nor refers to another model. The branch adds the User relation field and a migration, but schema.prisma has no model PasswordResetToken { … } block. Please add the model so it matches your migration. main has also moved to Prisma 7 (#151), so rebase onto the latest main first, then run npm run prisma:generate && npm run typecheck locally.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oyinkans0la12 Thanks for the PR. It can't be merged yet. To unblock it:

CI is failing. Here's what broke:

Backend (typecheck + test + build):

prisma generate: P1012 Type "PasswordResetToken" is neither a built-in type, nor refers to another model (prisma/schema.prisma:123) — the model definition is missing

Please fix these errors and run the same steps locally before pushing (npm run typecheck && npm test && npm run build, or cargo test for contracts).

Merge conflicts: this branch conflicts with main. Please rebase onto the latest main (git fetch upstream && git rebase upstream/main), resolve the conflicts (often ROADMAP.md, ARCHITECTURE*.md or prisma/schema.prisma), and force-push.

Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
The main merge resolution lost the PasswordResetToken model, config
block, repository functions, route/validation wiring and the tokenVersion
refresh check, leaving the branch unable to generate the Prisma client.
Restore them, and send the CSRF token on refresh in the integration test
now that /auth/refresh is CSRF-protected.
@github-actions github-actions Bot removed the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oyinkans0la12 Thanks for rebasing, there are no conflicts now. The backend CI failure (Error: Hook timed out in 30000ms, still failing after a re-run) isn't a flaky runner: it's an embedded-Postgres port collision.

Every integration test file starts its own Postgres on a fixed PORT, and vitest runs the files in parallel. Your new backend/tests/integration/password-reset.test.mts uses const PORT = 5436, which login-throttle.test.mts on main already uses, so one database can't start and that file's beforeAll times out.

Ports already taken on main are 5433 and 5435–5447. Change it to an unused one, for example const PORT = 5449;, then run npm run test:integration in backend/ and push. (If you also have #143 or #144 open, give each new test file its own unique port.)

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
@github-actions

Copy link
Copy Markdown

@Oyinkans0la12 This PR now has merge conflicts with main, most likely because #143 (feat(auth): session management: list and revoke active sessions per user) was just merged, so it can't be merged until it's rebased:

git fetch upstream            # upstream = https://github.com/DocForum/docforum-core.git
git rebase upstream/main
# resolve the conflicts, re-run the tests, then:
git push --force-with-lease

The usual culprits are shared files like ROADMAP.md and ARCHITECTURE*.md. CI re-runs when you push, and the needs-rebase label is removed automatically once the conflicts are resolved.

Automated message. It won't repeat unless the PR conflicts again after your next push.

@github-actions github-actions Bot removed the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
The merge with main kept the schema model but lost the User.tokenVersion
column, the `tv` claim on tokens, the two password-reset controller
handlers and routes, and the password-reset service re-exports — so
`prisma generate` produced a client without `tokenVersion`, typecheck
failed on unused imports and the missing `tv` field, and
`resetPasswordResetRateLimits` was unreachable from the test's import
surface.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oyinkans0la12 CI still fails with Error: Hook timed out in 30000ms in tests/integration/password-reset.test.mts. That file still starts its embedded Postgres on port 5436, which login-throttle.test.mts on main already uses, and vitest runs integration files in parallel, so they collide. Change const PORT to a free port (5448 is unused on main right now) and push.

5436 is login-throttle.test.mts's port. vitest runs integration files in
parallel and each boots its own embedded Postgres, so the two files raced
for the same port and password-reset's beforeAll hook timed out in CI.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>

@afeezorobsco-cyber afeezorobsco-cyber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed: no malicious code found, CI is green. Merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Phase 8] Password reset flow with single-use, hashed, expiring tokens

2 participants