From cc7c30ee20ef7dc41d240407de470ac2ac7f8f9f Mon Sep 17 00:00:00 2001 From: rock Date: Tue, 27 Oct 2026 00:14:42 -0700 Subject: [PATCH] fix(iam): actually enforce MFA forceMFA never took effect because of the JSON key. ZITADEL's v1 endpoint speaks protojson, which accepts "forceMfa" (lowerCamel of force_mfa). The spelling "forceMFA" is treated as an unknown field and silently dropped, so every earlier run returned 200 with a new sequence while force_mfa stayed false in projections.login_policies5. Send forceMfa and forceMfaLocalOnly. Verified in the database: force_mfa = t | force_mfa_local_only = t | mfa_init_skip_lifetime = 0 and in the login flow: after the password step the browser lands on /ui/login/mfa/prompt with the "2-Factor Setup" page, so a second factor is now required instead of optional. --- scripts/iam/zitadel-provision.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/iam/zitadel-provision.py b/scripts/iam/zitadel-provision.py index b0f7b6e..d39861e 100755 --- a/scripts/iam/zitadel-provision.py +++ b/scripts/iam/zitadel-provision.py @@ -420,15 +420,20 @@ def _ensure_smtp(token: str) -> str: def _ensure_login_policy(token: str) -> None: """Require password + a second factor, and demand enrolment at login. - forceMFA does not persist through the v1 policy endpoint (it reads back - absent), so the effective lever is mfaInitSkipLifetime: 0s, which makes the - login flow require enrolment instead of letting a user skip it for 30 days. + Enforcement needs forceMfa. The v1 endpoint ignores the spelling "forceMFA" + as an unknown field, which is why earlier runs looked successful while + projections.login_policies5 kept force_mfa = false. Second factors stay OTP and U2F: an authenticator app or a hardware key. Multi-factor is left configured for U2F with verification so a later step can move 2FA to 2MFA without another policy change. """ code, d = _api("PUT", "/admin/v1/policies/login", token, { + # Case matters: protojson accepts "forceMfa", the lowerCamel form of + # force_mfa. Sending "forceMFA" is silently ignored as an unknown field, + # so the policy looks unchanged and MFA is never enforced. + "forceMfa": True, + "forceMfaLocalOnly": True, "allowUsernamePassword": True, "allowRegister": False, "allowExternalIDP": True,