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
| Variable | Meaning |
|---|---|
MFA_TOTP_ENABLED | true turns the feature on. Anything else means off, and off is byte-identical to before the feature (FR-002). |
MFA_TOTP_PILOT_BUSINESS_IDS | A 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:
-
It returns at once if the switch is off, the route is
@IsPublic()or@AllowWithoutMfa(), or there is no db user. -
It resolves the business with the same rule as
RolesGuard,resolveRequestBusinessIdinroles/infrastructure/business-context.resolver.ts:businessIdfrom 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).
-
It loads the caller's role from
business_user. -
It calls
MfaEnforcementService.assertCompliant. That refuses when the policy isrequiredfor the role, the deadline has passed, and the token'sfirebase.sign_in_second_factoris 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 meansoptional. - Required roles are role names, because FlowPOS has no role ids.
- Deadline: entering
requiredsets now + 7 days. The deadline may only move earlier. Leavingrequiredclears 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).
- If the database read fails and the last known policy is
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-authchecks the device's business before it issues a bridge session.- MCP tokens (
mcp/tokenandmcp/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
| What | Where |
|---|---|
| Enrolled, removed, reset, reset outcome, notification sent | audit_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 changes | activity_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/resettakes{ 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--operatorand--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,useroraudit_logby 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
| Id | Residual |
|---|---|
| R1 | bridge-auth does not check that the caller belongs to the device's business (a pre-existing gap). |
| R3 | Routes whose business cannot be resolved are not enforced. Add @ResolveBusinessIdFromTable to such routes. |
| R4 | A reset user's sign-in sessions last up to 1 hour. |
| R4b | Print Bridge sessions last up to 8 hours. |
| R5 | Removing the authenticator happens client-side, between the browser and Firebase. |
| R6 | Some services look records up by id alone (#783). |
| R7 | Rate limits are per instance. |
See specs/060-mfa-totp/research.md for each one.