fix: make recovery code active on add (#11734)

# Which Problems Are Solved

- Recovery codes added via `GenerateRecoveryCodes` were never listed as
active authentication methods by `ListAuthenticationMethodTypes`. The
`user_auth_method` projection reducer (`reduceInitAuthMethod`) set the
state to `MFAStateNotReady`, expecting a subsequent verification event
to transition it to `MFAStateReady`. Unlike TOTP, U2F, and Passwordless
— which all have distinct verification events — recovery codes have no
such event, so they were permanently stuck in `NotReady`.

# How the Problems Are Solved

- Introduced a dedicated `reduceAddRecoveryCodes` reducer for
`HumanRecoveryCodesAddedEvent` that sets `MFAStateReady` immediately,
since recovery codes are usable as soon as they are generated.
- Uses `NewUpsertStatement` (instead of `NewCreateStatement`) so that
regenerating recovery codes for the same user updates the existing row
rather than failing on a duplicate key conflict.
- Removed the `HumanRecoveryCodesAddedEvent` case from
`reduceInitAuthMethod`.

# Additional Changes

- Added `UserAuthMethodTypeRecoveryCode` mapping in `authMethodTypeToPb`
unit test.
- Added integration tests for `ListAuthenticationMethodTypes` covering
both the "recovery codes present" and "recovery codes removed" cases.

# Additional Context

* https://github.com/zitadel/zitadel/issues/11317

---------

Co-authored-by: Marco A. <[email protected]>
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Zach Hirschtritt
2026-03-27 09:47:16 +00:00
committed by GitHub
co-authored by Marco A. Cursor
parent f99142bc4a
commit ec7a6d5526
4 changed files with 130 additions and 51 deletions
@@ -1972,6 +1972,24 @@ func TestServer_ListAuthenticationMethodTypes(t *testing.T) {
})
require.NoError(t, err)
userIDWithRecoveryCodes := Instance.CreateHumanUser(OrgCTX).GetUserId()
_, err = Client.GenerateRecoveryCodes(OrgCTX, &user.GenerateRecoveryCodesRequest{
UserId: userIDWithRecoveryCodes,
Count: 5,
})
require.NoError(t, err)
userIDWithRemovedRecoveryCodes := Instance.CreateHumanUser(OrgCTX).GetUserId()
_, err = Client.GenerateRecoveryCodes(OrgCTX, &user.GenerateRecoveryCodesRequest{
UserId: userIDWithRemovedRecoveryCodes,
Count: 5,
})
require.NoError(t, err)
_, err = Client.RemoveRecoveryCodes(OrgCTX, &user.RemoveRecoveryCodesRequest{
UserId: userIDWithRemovedRecoveryCodes,
})
require.NoError(t, err)
_, userLegacyID := ctxFromNewUserWithVerifiedPasswordlessLegacy(t)
require.NoError(t, err)
@@ -2149,6 +2167,37 @@ func TestServer_ListAuthenticationMethodTypes(t *testing.T) {
},
},
},
{
name: "with auth (recovery codes)",
args: args{
OrgCTX,
&user.ListAuthenticationMethodTypesRequest{
UserId: userIDWithRecoveryCodes,
},
},
want: &user.ListAuthenticationMethodTypesResponse{
Details: &object.ListDetails{
TotalResult: 1,
},
AuthMethodTypes: []user.AuthenticationMethodType{
user.AuthenticationMethodType_AUTHENTICATION_METHOD_TYPE_RECOVERY_CODE,
},
},
},
{
name: "with removed recovery codes",
args: args{
OrgCTX,
&user.ListAuthenticationMethodTypesRequest{
UserId: userIDWithRemovedRecoveryCodes,
},
},
want: &user.ListAuthenticationMethodTypesResponse{
Details: &object.ListDetails{
TotalResult: 0,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {