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
+29 -4
View File
@@ -131,7 +131,7 @@ func (p *userAuthMethodProjection) Reducers() []handler.AggregateReducer {
},
{
Event: user.HumanRecoveryCodesAddedType,
Reduce: p.reduceInitAuthMethod,
Reduce: p.reduceAddRecoveryCodes,
},
{
Event: user.HumanRecoveryCodesRemovedType,
@@ -175,10 +175,8 @@ func (p *userAuthMethodProjection) reduceInitAuthMethod(event eventstore.Event)
rpID = &e.RPID
case *user.HumanOTPAddedEvent:
methodType = domain.UserAuthMethodTypeTOTP
case *user.HumanRecoveryCodesAddedEvent:
methodType = domain.UserAuthMethodTypeRecoveryCode
default:
return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType, user.HumanMFAOTPAddedType, user.HumanRecoveryCodesAddedType})
return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-f92f", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanPasswordlessTokenAddedType, user.HumanU2FTokenAddedType, user.HumanMFAOTPAddedType})
}
cols := []handler.Column{
handler.NewCol(UserAuthMethodTokenIDCol, tokenID),
@@ -273,6 +271,33 @@ func (p *userAuthMethodProjection) reduceAddAuthMethod(event eventstore.Event) (
), nil
}
func (p *userAuthMethodProjection) reduceAddRecoveryCodes(event eventstore.Event) (*handler.Statement, error) {
if _, ok := event.(*user.HumanRecoveryCodesAddedEvent); !ok {
return nil, zerrors.ThrowInvalidArgumentf(nil, "PROJE-x93hq", "reduce.wrong.event.type %v", []eventstore.EventType{user.HumanRecoveryCodesAddedType})
}
return handler.NewUpsertStatement(
event,
[]handler.Column{
handler.NewCol(UserAuthMethodInstanceIDCol, nil),
handler.NewCol(UserAuthMethodUserIDCol, nil),
handler.NewCol(UserAuthMethodTypeCol, nil),
handler.NewCol(UserAuthMethodTokenIDCol, nil),
},
[]handler.Column{
handler.NewCol(UserAuthMethodTokenIDCol, ""),
handler.NewCol(UserAuthMethodCreationDateCol, handler.OnlySetValueOnInsert(UserAuthMethodTable, event.CreatedAt())),
handler.NewCol(UserAuthMethodChangeDateCol, event.CreatedAt()),
handler.NewCol(UserAuthMethodResourceOwnerCol, event.Aggregate().ResourceOwner),
handler.NewCol(UserAuthMethodInstanceIDCol, event.Aggregate().InstanceID),
handler.NewCol(UserAuthMethodUserIDCol, event.Aggregate().ID),
handler.NewCol(UserAuthMethodSequenceCol, event.Sequence()),
handler.NewCol(UserAuthMethodStateCol, domain.MFAStateReady),
handler.NewCol(UserAuthMethodTypeCol, domain.UserAuthMethodTypeRecoveryCode),
handler.NewCol(UserAuthMethodNameCol, ""),
},
), nil
}
func (p *userAuthMethodProjection) reduceRemoveAuthMethod(event eventstore.Event) (*handler.Statement, error) {
var tokenID string
var methodType domain.UserAuthMethodType