Skip to main content

Two-step verification (TOTP)

Optional authenticator-app two-step verification (feature 060). Firebase Identity Platform owns the secret and verifies codes. FlowPOS reads one claim from the verified ID token and applies a per-business policy.

The design record is in specs/060-mfa-totp/ in the repository (spec, plan, research, data model, contracts, quickstart).

The switch​

VariableMeaning
MFA_TOTP_ENABLEDtrue turns the feature on. Anything else means off, and off is byte-identical to before the feature (FR-002).
MFA_TOTP_PILOT_BUSINESS_IDSA comma-separated list of business uuids. Empty or unset means no business, so a missing value fails closed; * means every business (general availability). Required alongside the switch.

Set both in Doppler and in the Cloud Run deploy environment; Doppler does not sync to GitHub Actions.

Before turning the switch on in an environment, run the setup script and confirm TOTP shows ENABLED:

pnpm --filter @flowpos-workspace/backend-scripts run mfa:enable-totp            # dry run
pnpm --filter @flowpos-workspace/backend-scripts run mfa:enable-totp -- --apply

How enforcement works​

MfaGuard (apps/backend/src/mfa/interfaces/mfa.guard.ts) is a global APP_GUARD registered right after AuthGuard. On every authenticated request it does the following:

  1. It returns at once if the switch is off, the route is @IsPublic() or @AllowWithoutMfa(), or there is no db user.

  2. It resolves the business with the same rule as RolesGuard, resolveRequestBusinessId in roles/infrastructure/business-context.resolver.ts:

    • businessId from the body, params or query;
    • otherwise /businesses/:id;
    • otherwise the location's business;
    • otherwise @ResolveBusinessIdFromTable.

    If none of these yields a business, no policy applies (clarification Q1).

  3. It loads the caller's role from business_user.

  4. It calls MfaEnforcementService.assertCompliant. That refuses when the policy is required for the role, the deadline has passed, and the token's firebase.sign_in_second_factor is not "totp".

Why it's global and not a RolesGuard step: about 69 business controllers (orders, inventory, cash register and others) are not behind RolesGuard.

The refusal is a 403 whose body carries error: "MFA_ENROLLMENT_REQUIRED" | "MFA_REQUIRED", plus details.businessId and details.enforcedFrom. Which of the two codes is chosen uses the cached user.mfa_enrolled_at. Whether to refuse never uses it (FR-025).

The policy​

  • Table: business_security_policy, one row per business. No row means optional.
  • Required roles are role names, because FlowPOS has no role ids.
  • Deadline: entering required sets now + 7 days. The deadline may only move earlier. Leaving required clears it, so re-entering starts a fresh grace period.
  • Reads are memoized per instance for 60 seconds.
    • If the database read fails and the last known policy is required, the request is refused (fail closed).
    • If the read fails and nothing is known, the request is allowed and the failure goes to Sentry (fail open).

Compliance routes​

@AllowWithoutMfa() marks the routes a blocked user needs in order to comply (contracts §1). The set is pinned by mfa/__tests__/mfa-route-coverage.spec.ts. Adding a route requires two things: a reason in mfa-route-coverage.allowlist.ts, and an update to contracts §1.

Other enforcement points​

  • kitchen-stations/bridge-auth checks the device's business before it issues a bridge session.
  • MCP tokens (mcp/token and mcp/token/refresh):
    • a token minted without a code session omits every business that enforces for the caller;
    • the token carries mfa;
    • refresh refuses a token issued before the user's most recent support reset (user.mfa_reset_at).

Audit​

WhatWhere
Enrolled, removed, reset, reset outcome, notification sentaudit_log, with entity = 'user_mfa'. Insert-only, and every row carries new.correlationId. A later fact about an event, such as a reset's outcome or a notice result, is a new row with the same id.
Policy changesactivity_log, with entity_type = 'business_settings', classified sensitive

An orphaned reset is a reset_by_support row with no reset_by_support_outcome row sharing its correlation id after 10 minutes. Monitoring for it is a GA gate.

Support reset and break-glass​

  • In app (staff only):

    • GET /platform-billing/users/mfa?email= looks the user up.
    • POST /platform-billing/users/:userId/mfa/reset takes { reason } and performs the reset.
    • An operator cannot reset their own account in the app.
  • Break-glass (an operator's own account, or an emergency policy rollback). Both scripts are dry runs unless given --apply, and both require --operator and --reason:

    pnpm --filter backend run mfa:break-glass-reset -- --user-id <uuid> --operator "<name>" --reason "<…>" [--apply]
    pnpm --filter backend run mfa:set-policy -- --business-id <uuid> --policy optional --operator "<name>" --reason "<…>" [--apply]

    Never edit business_security_policy, user or audit_log by hand.

Each reset and each enrollment change emails the user, and a reset also emails the owners of the user's businesses (FR-034). Outside production and beta, notices go to COMMUNICATION_EMAIL_OVERRIDE or are skipped.

Known residuals​

IdResidual
R1bridge-auth does not check that the caller belongs to the device's business (a pre-existing gap).
R3Routes whose business cannot be resolved are not enforced. Add @ResolveBusinessIdFromTable to such routes.
R4A reset user's sign-in sessions last up to 1 hour.
R4bPrint Bridge sessions last up to 8 hours.
R5Removing the authenticator happens client-side, between the browser and Firebase.
R6Some services look records up by id alone (#783).
R7Rate limits are per instance.

See specs/060-mfa-totp/research.md for each one.