Skip to content

fix(auth): a started but unfinished MFA enrolment can never be retried — setup answers 400 "duplicated key" #714

Closed

Description

@alex-dembele

Problem

A user who opens MFA enrolment and does not finish it can never enrol again.

The first POST /api/v1/auth/mfa/setup stores an unverified secret. Every later call tries to INSERT a second row, hits the UNIQUE index on mfa_secrets.user_id, and answers 400 "failed to store MFA secret: duplicated key not allowed". Closing the dialog, reloading the page, losing the phone before scanning, or a network blip after the first call all leave the account in this state. The UI then shows "Impossible de démarrer l'activation. Réessayez…"; retrying cannot succeed.

This matters most for a role under the MFA policy. The banner says that once the grace period ends, "l'accès sera bloqué jusqu'à l'activation du MFA", and activation is exactly what the account can no longer do. The only way out is a manual DB delete.

The frontend already knows the endpoint is not idempotent and guards against React's double effect: frontend/src/features/auth/MFAEnrollmentDialog.tsx:42 and frontend/src/features/auth/AuthScreen.tsx:395. That guard hides the defect in the common case; the backend was never fixed.

Found on 2026-09-17 while capturing visual diffs for #524.

Reproduction (verified 2026-09-17, local stack, master 53c5f29)

POST /auth/register  → 201   (fresh account)
POST /auth/login     → 200
POST /auth/mfa/setup → 200   (QR code returned)
POST /auth/mfa/setup → 400   {"error":"failed to store MFA secret: duplicated key not allowed"}

Cause: backend/internal/application/auth/mfa_usecase.go SetupMFAUseCase.Execute refuses only when existingSecret.IsVerified. When a secret exists but is unverified it still calls CreateMFASecret (a plain GORM Create), and mfa_secrets has idx_mfa_secrets_user_id UNIQUE (user_id).

Two related defects in the same function:

  • The secret write and the backup-code writes (DeleteBackupCodes, SaveBackupCodes) are separate statements with no transaction (ABSOLUTE RULE 7). A failure in the middle leaves a secret with no backup codes.
  • The raw storage error reaches the client as a 400 instead of a typed error (ABSOLUTE RULE 3).

Acceptance criteria

  1. Given an account with an unverified MFA secret, when POST /auth/mfa/setup is called again, then it answers 200 with a new secret and QR code. The stored secret is replaced, so only the newest QR code verifies.
  2. Given an account with a verified MFA secret, when POST /auth/mfa/setup is called, then it still answers with the typed conflict error (ErrConflict, already_enabled), unchanged.
  3. Given setup is called N times before verification, then exactly one mfa_secrets row exists for the user, and exactly one set of unused backup codes (the latest).
  4. Given a failure while storing backup codes, then no new secret is persisted: secret and codes are written in one transaction.
  5. Given any storage failure, the response is a typed error and never includes the database message.
  6. Every query stays scoped by tenant_id, as today.

Definition of Done

  • tenant_id filtering on every query touched
  • TestSetupMFA_Success, TestSetupMFA_NotFound, TestSetupMFA_Unauthorized, plus TestSetupMFA_ReplacesUnverifiedSecret and TestSetupMFA_VerifiedConflict
  • Transaction across mfa_secrets and mfa_backup_codes
  • FR + EN strings: N/A unless the error copy changes
  • Security review (devsecops): this touches auth. It is a fix, not a redesign of the MFA flow
  • The frontend "not idempotent" guard comments are updated, or removed if no longer needed
  • Claim matrix: no-op

Scope OUT

  • Changing the TOTP, encryption or backup-code scheme.
  • The MFA policy and grace period.

Milestone chosen by the reporting agent (auth reliability belongs with trust); the PO may re-milestone.

Activity

  1. added this to the trust-v1 milestone on Sep 17, 2026
  2. alex-dembele commented on Oct 7, 2026

    @alex-dembele
    MemberAuthor

    backend — 2026-10-07

    Done — PR #898, branch fix/714-mfa-setup-atomic (worktree OpenRisk-wt-714). #889 had already fixed criteria 1 and 2. This PR adds:

    • MFARepository.StartMFAEnrolment writes the secret (upsert, unverified rows of the same tenant only), the code delete and the code insert in one transaction — backend/internal/infrastructure/repository/gorm_mfa_repository.go, mfa_repository_interface.go. ReplaceUnverifiedMFASecret removed (no caller left).
    • Setup hashes the codes first, then makes one repository call — backend/internal/application/auth/mfa_usecase.go.
    • mapAuthError: an untyped error now answers 500 with the generic FR/EN text and is logged — backend/internal/handler/auth/mfa_handler.go.
    • Found while testing: secrets soft-deleted before security(auth): disabling MFA does not re-verify the password #754 blocked setup forever; they are now reused as a fresh enrolment.
    • Frontend guard comments rewritten (no code change) — AuthScreen.tsx, MFAEnrollmentDialog.tsx.
    • Spec docs/714_MFA_SETUP_ATOMIC.md; D-066 raised in docs/DECISIONS.md; follow-up fix(auth): a person in two organisations can enrol MFA in only one, because mfa_secrets.user_id is unique #897.

    Verified

    • go test ./... → 80 packages ok, no FAIL.
    • DATABASE_URL=<throwaway pg16> go test -race on application/auth, infrastructure/repository, handler/auth → ok ×3, including StartMFAEnrolment_{,ReusesATombstone,RollsBackWhenTheCodesFail,ConcurrentFirstSetup}_Postgres.
    • Mutation checks: dropping the transaction, the is_verified guard, the upsert or the tombstone clause each fails a test.
    • golangci-lint v1.64.8 --new-from-rev=origin/master on the touched packages → clean; npx tsc -b --noEmit → clean.
    • Live, branch server on a throwaway Postgres: setup ×3 → 200, 1 row, 8 codes; first QR code refused, last accepted; setup after verification → 409; code insert forced to fail → 500 with the generic text, secret and codes unchanged; verified tombstone → setup 200, verify 200.

    Criteria — 1 ✅ (#889) · 2 ✅ (#889) · 3 ✅ · 4 ✅ · 5 ✅ · 6 ✅
    DoD: tests ✅. #889's …ReplacesAnUnverifiedSecret / …VerifiedSecretIsKept are kept as this issue's …ReplacesUnverifiedSecret / …VerifiedConflict (owner's call). Transaction ✅ · FR/EN: no new strings, existing genericFailure reused ✅ · frontend comments ✅ · claim matrix no-op ✅ · devsecops review ❌ not run as a separate agent pass. The diff was self-reviewed for tenant scope, logging and the verified path.

    Next — Owner review of PR #898; optionally a devsecops pass first. Then the merge.

    Blocked on — nothing for #714. #897 is blocked on the owner's decision D-066.

  3. added a commit that references this issue on Oct 7, 2026
  4. alex-dembele commented on Oct 10, 2026

    @alex-dembele
    MemberAuthor

    devsecops review — 2026-10-11

    Done — Security review of PR #898 (merged; head 3fe842b), the last open DoD item. Read-only review of the diff 1b5be5d6^..3fe842b6, plus the repository, use cases, handler, middlewares and route wiring. Live run on a throwaway Postgres and Redis.

    Verified

    Pre-existing findings outside the diff, filed:

    Criteria — DoD "Security review (devsecops)" ✅ for #714.

    Next — #944 first; it blocks any release. Then #946.

    Blocked on — nothing. D-066 has been decided (one factor per organisation), and #897 is status:ready.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions