Repository navigation
feat(auth): password reset flow with single-use, hashed, expiring tokens - #140
Conversation
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
|
@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! 🚀 |
|
@Oyinkans0la12 pls fix conflict |
|
CI is failing on this PR.
Please push a fix and CI will re-run. Before pushing, run |
|
CI fails at |
|
@Oyinkans0la12 Thanks for the PR. It can't be merged yet. To unblock it: CI is failing. Here's what broke:
Please fix these errors and run the same steps locally before pushing ( Merge conflicts: this branch conflicts with Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge. |
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.
|
@Oyinkans0la12 Thanks for rebasing, there are no conflicts now. The backend CI failure ( Every integration test file starts its own Postgres on a fixed Ports already taken on |
|
@Oyinkans0la12 This PR now has merge conflicts with 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-leaseThe usual culprits are shared files like Automated message. It won't repeat unless the PR conflicts again after your next push. |
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>
|
@Oyinkans0la12 CI still fails with |
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
left a comment
There was a problem hiding this comment.
Reviewed: no malicious code found, CI is green. Merging.
What changed
Implements the password reset flow from #33:
POST /auth/password-reset/requestand
POST /auth/password-reset/confirm, plus the storage and revocationplumbing 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
/requestreturns202with an identicalbody 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
429from rate limiting, which fires before anyaccount lookup.
exists in memory and in the outbound notification only.
PasswordResetToken.tokenHashis a unique SHA-256 index; confirm comparesconstant-time with
crypto.timingSafeEqualafter the indexed lookup.used_atcovers "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.
hash and increments a new
User.tokenVersionin one transaction. Everytoken carries
tvandPOST /auth/refreshcompares it to the column — so areset 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.
notificationsmodule'sNotificationChannelinterface, with a console/devadapter 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
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.
TTL; there is no per-request revocation check.
NotificationDB row is written for outbound messages — that model isstill the Phase 0 placeholder without
userId, and the in-app feed is Phase6. 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 thetvclaim),ROADMAP.md(Phase 8 item + changelog line).Tests
npm run test→ 24 passed.npm run test:integration→ 9 passed (1 existingbooking + 8 new).
npm run typecheckandnpm run buildclean.New integration test (
tests/integration/password-reset.test.mts, realPostgres via the existing embedded-Postgres pattern) covers:
notification
(same generic error)
401)429after 3 requestsNote:
npm run test:allalso runstests/testnet-integration, which requiresDATABASE_URL_TESTand testnet access and fails without them — pre-existing,not part of
ci.yml(which runstest+test:integration).