Skip to content

feat(auth): TOTP two-factor authentication for doctor, facility and admin roles - #142

Open
Oyinkans0la12 wants to merge 7 commits into
DocForum:mainfrom
Oyinkans0la12:feat/totp-two-factor-authentication
Open

Oyinkans0la12 wants to merge 7 commits into
DocForum:mainfrom
Oyinkans0la12:feat/totp-two-factor-authentication

Conversation

@Oyinkans0la12

Copy link
Copy Markdown
Contributor

What changed

Implements TOTP two-factor authentication from #36: enrollment
(POST /auth/2fa/setup → POST /auth/2fa/enable), two-step login
(POST /auth/2fa/verify), 10 single-use recovery codes, POST /auth/2fa/disable,
GET /auth/2fa, and the REQUIRE_2FA_FOR_ADMIN config flag.

Closes #36

Why

Doctor, facility and admin accounts can read clinical data or move escrowed
funds; a password alone is a weak control for those roles. TOTP adds a second
factor with no SMS/email provider dependency, so it ships while #17 is still
undecided.

Implementation notes

  • No new dependency. TOTP is RFC 6238 on node:crypto
    (auth/services/totp.ts) — avoiding a provider/package dependency is the
    whole reason TOTP was chosen over SMS. Correctness is pinned by the RFC's own
    published test vectors (SHA-1, secret 12345678901234567890), not by
    self-consistent expectations. No ARCHITECTURE.md §2 stack change to record.
  • Secret encrypted at rest. secretEncrypted is AES-256-GCM
    (iv.tag.ciphertext, key derived from TOTP_ENCRYPTION_KEY) and is never
    returned by the API after the enrollment URI. Missing key ⇒ a loud, named
    error rather than a plaintext fallback.
  • The mfa_pending token can't reach anything else. The password step for
    an enrolled user returns no session at all — just a 5-minute token signed
    with the access-token secret but carrying scope: 'mfa_pending'.
    verifyAccessToken rejects any token with a scope, so every authenticated
    route refuses it; verifyMfaPendingToken accepts only that claim, and only
    POST /auth/2fa/verify calls it. Tested in both directions (an
    mfa_pending token on /doctors/me and /auth/2fa/setup → 401; a real
    access token offered to /auth/2fa/verify → 401).
  • Replay protection. The highest accepted TOTP time step is stored per user
    (lastUsedTimeStep); a code at or below it is rejected even inside the ±1
    window. Integration-tested: the same code succeeds once, then 401.
  • Recovery codes. 10 per enrollment, generated with unbiased rejection
    sampling, displayed exactly once, stored bcrypt-hashed, marked used on first
    success (tested: reuse → 401, next code → 200).
  • Disable requires proof of possession — a current TOTP code or a recovery
    code, not just a password-authenticated session.
  • REQUIRE_2FA_FOR_ADMIN enforces a second factor for admin logins
    (checked after the password is verified, so it isn't an admin-account
    enumerator). Default false.

Schema change (proposed)

  • TwoFactorAuth: id, userId (unique), secretEncrypted, enabledAt (nullable), lastUsedTimeStep (nullable), createdAt, updatedAt — ARCHITECTURE.md §4.9.
  • TwoFactorRecoveryCode: id, twoFactorAuthId, codeHash, usedAt, createdAt —
    §4.9.

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

New env var TOTP_ENCRYPTION_KEY (documented in .env.example), also added to
vitest.config.mts's test env.

Assumptions / gaps flagged, not silently resolved

  • Admin bootstrap caveat: with REQUIRE_2FA_FOR_ADMIN=true, an admin who
    has not enrolled cannot log in to reach /auth/2fa/setup, so the flag should
    only be switched on after admins have enrolled (or once admin-invited
    provisioning / a one-time enrollment token exists). Recorded in
    ARCHITECTURE_ESSENTIALS.md known gaps.
  • One process-wide encryption key, no rotation or per-user key — also recorded
    as a known gap.
  • An mfa_pending token stays usable until its 5-minute expiry after a
    successful verify; replay protection already makes a code single-use, so
    this isn't a second-factor bypass, but there's no token-revocation store yet
    (that's [Phase 8] Session management: list and revoke active sessions per user #38/[Phase 8] JWT refresh-token revocation #18 territory).
  • Signup still returns a session immediately; 2FA only gates subsequent logins.
    Not specified in the issue, flagged rather than invented differently.

Cross-PR note

auth/services/tokens.ts and ARCHITECTURE.md §4 are also touched by #140
(the still-open password reset PR). Whichever lands second needs a trivial
rebase/numbering fix — called out here so it isn't a surprise.

Docs updated in the same PR

ARCHITECTURE.md (§4.9 models, §4.10 token scopes), ARCHITECTURE_ESSENTIALS.md
(data model map + two new known gaps), docs/api/README.md (all five endpoints

  • the two-step login sequence), ROADMAP.md (Phase 8 item + changelog).

Tests

npm run typecheck and npm run build clean. npm run test → 36 passed.
npm run test:integration → 11 passed (1 existing booking + 10 new).

Unit: RFC 6238 test vectors at six timestamps, base32 round-trip/rejection,
±1 window acceptance and ±2 rejection, replay rejection, otpauth URI params,
recovery-code normalization, AES-GCM round-trip / unique IV / tamper detection.

Integration (tests/integration/two-factor.test.mts, real Postgres): the flow
above end to end, plus enrollment-doesn't-enforce-until-confirmed, no cookie or
access token on the first login step, recovery-code single-use, disable →
single-step login again, and the admin flag (403 for an unenrolled admin, while
a non-admin still logs in — the flag is role-scoped).

Test Files  2 passed (2)
     Tests  11 passed (11)   # npm run test:integration
Test Files  5 passed (5)
     Tests  36 passed (36)   # npm run test

…dmin roles

Adds enrollment (POST /auth/2fa/setup -> otpauth URI, then
POST /auth/2fa/enable with a first code), two-step login
(POST /auth/2fa/verify), 10 single-use recovery codes, disable, and a
status endpoint.

TOTP is RFC 6238 implemented on node:crypto rather than a new dependency
-- avoiding a provider dependency is why TOTP was chosen over SMS, and
the algorithm is pinned by the RFC's own published test vectors. The
shared secret is AES-256-GCM encrypted at rest with TOTP_ENCRYPTION_KEY.

An enrolled user's password step returns a short-lived mfa_pending token
instead of a session. It is signed with the access-token secret but
carries scope: 'mfa_pending', and verifyAccessToken refuses any scoped
token, so it is accepted by exactly one route and by no protected route
(tested in both directions). Replay is rejected by storing the highest
spent time step per user; recovery codes are bcrypt-hashed and single-use;
disabling requires proof of possession.

REQUIRE_2FA_FOR_ADMIN makes 2FA mandatory for admin logins; its bootstrap
caveat is flagged in ARCHITECTURE_ESSENTIALS.md rather than papered over.

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

Refs DocForum#36
@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

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

prisma:generate fails: Type "TwoFactorAuth" is neither a built-in type, nor refers to another model. The model TwoFactorAuth block was dropped from backend/prisma/schema.prisma when you merged main in (af5aca6), but the 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 "TwoFactorAuth" 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 TwoFactorAuth { … } 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 "TwoFactorAuth" is neither a built-in type, nor refers to another model (prisma/schema.prisma:115) — 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
Oyinkans0la12 and others added 3 commits October 20, 2026 12:23
The main merge resolution dropped the TwoFactorAuth/TwoFactorRecoveryCode
models, the twoFactor config block, the /auth/2fa routes, validation
schemas and the mfa_pending login path, so the Prisma client could not be
generated and the login service had no second-step handling.
@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/two-factor.test.mts uses const PORT = 5437, which csrf.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 = 5448;, 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
@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oyinkans0la12 CI is failing on this branch (after it was updated with the latest main), so it can't be merged yet. The errors from the latest run:

src/modules/auth/controllers/index.ts(8,3): error TS6133: 'twoFactorCodeSchema' is declared but its value is never read.
src/modules/auth/controllers/index.ts(9,3): error TS6133: 'twoFactorVerifySchema' is declared but its value is never read.
src/modules/auth/services/tokens.ts(57,15): error TS2339: Property 'scope' does not exist on type 'AccessTokenPayload'.
src/modules/auth/services/tokens.ts(63,15): error TS2339: Property 'scope' does not exist on type 'AccessTokenPayload'.
src/modules/auth/services/two-factor.ts(15,3): error TS2305: Module '"../repositories"' has no exported member 'confirmTwoFactorEnablement'.
src/modules/auth/services/two-factor.ts(16,3): error TS2305: Module '"../repositories"' has no exported member 'deleteTwoFactor'.
src/modules/auth/services/two-factor.ts(17,3): error TS2305: Module '"../repositories"' has no exported member 'findTwoFactorByUserId'.
src/modules/auth/services/two-factor.ts(19,3): error TS2305: Module '"../repositories"' has no exported member 'markRecoveryCodeUsed'.
src/modules/auth/services/two-factor.ts(20,3): error TS2305: Module '"../repositories"' has no exported member 'recordTotpTimeStep'.
src/modules/auth/services/two-factor.ts(21,3): error TS2305: Module '"../repositories"' has no exported member 'upsertTwoFactorEnrollment'.

The two-factor repository functions that services/two-factor.ts imports aren't exported from auth/repositories, and AccessTokenPayload has no scope field. Please check that your rebase didn't drop part of the change. CI will re-run on your next push.

The merge with main kept the tests but lost the TwoFactorAuth /
TwoFactorRecoveryCode models, the repository functions, the /auth/2fa
routes and controllers, the mfa_pending login branch in the service, and
the `scope` claim on AccessTokenPayload — so typecheck failed on unused
schema imports, missing repository exports and the undeclared `scope`
field, and the Prisma client had no twoFactorAuth/twoFactorRecoveryCode
delegates for the integration test to use.

Also move two-factor.test.mts off port 5437: that is csrf.test.mts's port,
and vitest runs integration files in parallel, each with its own embedded
Postgres.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
@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 #140 (feat(auth): password reset flow with single-use, hashed, expiring tokens) 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.

This branch has not been deployed

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

Labels

needs-rebase Has merge conflicts with main; the author needs to rebase

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Phase 8] TOTP two-factor authentication for doctor, facility and admin roles

2 participants