fix: allow invite codes for users whose auth methods were all removed (#12453)

# Which Problems Are Solved

When a user has no primary auth method (no password, passkey, or IDP
link), the login app routes them to the verify page and lets them
(re)send an invite code. But clicking **Resend** failed with
`Errors.User.AlreadyInitialised` (surfaced in the login UI as the
misleading "User is already verified!").

The cause was in the invite write model (`UserV2InviteWriteModel`): its
`AuthMethodSet` flag was **sticky**. It was set to `true` the first time
a user ever got a password, passkey, or IDP link, and was never cleared
— the removal events weren't even loaded by the query. So a user who
*once* had an auth method that was later removed was permanently treated
as "initialized," even though `ListUserAuthMethodTypes` (what the login
app checks) correctly reported zero methods. That disagreement blocked
invite creation/resend for legitimately method-less users.

# How the Problems Are Solved

Track the user's auth methods as the **current set** instead of a sticky
flag:
- Replaced the `AuthMethodSet bool` field with an `authMethods` set,
keyed uniquely per method (password / IDP link / passkey).
- Reduce now also handles the removal events (`UserIDPLinkRemoved`,
`UserIDPLinkCascadeRemoved`, `HumanPasswordlessTokenRemoved`), and these
event types were added to the write model's query filter so they're
loaded.
- `AuthMethodSet()` is now simply "does the user currently have ≥1 auth
method," so `CreationAllowed()` reflects reality.

The security guard is unchanged: a user who still has any auth method is
refused. The only behavior change is that a user with **no current**
auth method (including one whose methods were all removed) can again
receive an invite code — matching what the login app already assumes. No
extra queries (same single event filter, a few more event types), and no
data migration (pure re-derivation from existing events).

Added tests covering both an initialized user (still refused) and invite
creation succeeding after all auth methods are removed.
This commit is contained in:
Max Peintner
2026-08-12 06:24:28 +00:00
committed by GitHub
parent 318bfc36d7
commit 11912ac749
2 changed files with 173 additions and 9 deletions
+65 -9
View File
@@ -22,13 +22,54 @@ type UserV2InviteWriteModel struct {
URLTemplate string URLTemplate string
CodeReturned bool CodeReturned bool
EmailVerified bool EmailVerified bool
AuthMethodSet bool
// authMethods holds the primary auth methods (password, passkeys, IDP links)
// the user currently has, keyed uniquely per method. It is tracked as the
// current set rather than a sticky flag, so that removing all auth methods
// re-enables the invite flow, mirroring what ListUserAuthMethodTypes reports.
authMethods map[authMethodKey]struct{}
UserState domain.UserState UserState domain.UserState
} }
// authMethodKey uniquely identifies a single primary auth method within the
// authMethods set.
// ID components (e.g. an externalUserID supplied by an external IDP) cannot
// collide across method instances regardless of their contents.
type authMethodKey struct {
kind string
idpConfigID string
externalUserID string
passkeyTokenID string
}
func passwordAuthMethodKey() authMethodKey {
return authMethodKey{kind: "password"}
}
func idpAuthMethodKey(idpConfigID, externalUserID string) authMethodKey {
return authMethodKey{kind: "idp", idpConfigID: idpConfigID, externalUserID: externalUserID}
}
func passkeyAuthMethodKey(tokenID string) authMethodKey {
return authMethodKey{kind: "passkey", passkeyTokenID: tokenID}
}
// AuthMethodSet reports whether the user currently has at least one primary
// auth method (password, passkey or IDP link) set up.
func (wm *UserV2InviteWriteModel) AuthMethodSet() bool {
return len(wm.authMethods) > 0
}
func (wm *UserV2InviteWriteModel) CreationAllowed() bool { func (wm *UserV2InviteWriteModel) CreationAllowed() bool {
return !wm.AuthMethodSet return !wm.AuthMethodSet()
}
func (wm *UserV2InviteWriteModel) addAuthMethod(key authMethodKey) {
if wm.authMethods == nil {
wm.authMethods = make(map[authMethodKey]struct{})
}
wm.authMethods[key] = struct{}{}
} }
func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel { func newUserV2InviteWriteModel(userID, orgID string) *UserV2InviteWriteModel {
@@ -51,7 +92,10 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
wm.CodeReturned = false wm.CodeReturned = false
wm.EmailVerified = false wm.EmailVerified = false
wm.UserState = domain.UserStateActive wm.UserState = domain.UserStateActive
wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" wm.authMethods = nil
if crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" {
wm.addAuthMethod(passwordAuthMethodKey())
}
case *user.HumanRegisteredEvent: case *user.HumanRegisteredEvent:
wm.EmptyInviteCode() wm.EmptyInviteCode()
wm.ApplicationName = "" wm.ApplicationName = ""
@@ -60,7 +104,10 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
wm.CodeReturned = false wm.CodeReturned = false
wm.EmailVerified = false wm.EmailVerified = false
wm.UserState = domain.UserStateActive wm.UserState = domain.UserStateActive
wm.AuthMethodSet = crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" wm.authMethods = nil
if crypto.SecretOrEncodedHash(e.Secret, e.EncodedHash) != "" {
wm.addAuthMethod(passwordAuthMethodKey())
}
case *user.MachineAddedEvent: case *user.MachineAddedEvent:
wm.EmptyInviteCode() wm.EmptyInviteCode()
wm.ApplicationName = "" wm.ApplicationName = ""
@@ -68,7 +115,7 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
wm.URLTemplate = "" wm.URLTemplate = ""
wm.CodeReturned = false wm.CodeReturned = false
wm.EmailVerified = false wm.EmailVerified = false
wm.AuthMethodSet = false wm.authMethods = nil
case *user.HumanInviteCodeAddedEvent: case *user.HumanInviteCodeAddedEvent:
wm.SetInviteCode(e.Code, e.Expiry, e.CreationDate()) wm.SetInviteCode(e.Code, e.Expiry, e.CreationDate())
wm.URLTemplate = e.URLTemplate wm.URLTemplate = e.URLTemplate
@@ -103,14 +150,20 @@ func (wm *UserV2InviteWriteModel) Reduce() error {
wm.URLTemplate = "" wm.URLTemplate = ""
wm.CodeReturned = false wm.CodeReturned = false
wm.EmailVerified = false wm.EmailVerified = false
wm.AuthMethodSet = false wm.authMethods = nil
wm.UserState = domain.UserStateDeleted wm.UserState = domain.UserStateDeleted
case *user.HumanPasswordChangedEvent: case *user.HumanPasswordChangedEvent:
wm.AuthMethodSet = true wm.addAuthMethod(passwordAuthMethodKey())
case *user.UserIDPLinkAddedEvent: case *user.UserIDPLinkAddedEvent:
wm.AuthMethodSet = true wm.addAuthMethod(idpAuthMethodKey(e.IDPConfigID, e.ExternalUserID))
case *user.UserIDPLinkRemovedEvent:
delete(wm.authMethods, idpAuthMethodKey(e.IDPConfigID, e.ExternalUserID))
case *user.UserIDPLinkCascadeRemovedEvent:
delete(wm.authMethods, idpAuthMethodKey(e.IDPConfigID, e.ExternalUserID))
case *user.HumanPasswordlessVerifiedEvent: case *user.HumanPasswordlessVerifiedEvent:
wm.AuthMethodSet = true wm.addAuthMethod(passkeyAuthMethodKey(e.WebAuthNTokenID))
case *user.HumanPasswordlessRemovedEvent:
delete(wm.authMethods, passkeyAuthMethodKey(e.WebAuthNTokenID))
} }
} }
return wm.WriteModel.Reduce() return wm.WriteModel.Reduce()
@@ -153,7 +206,10 @@ func (wm *UserV2InviteWriteModel) Query() *eventstore.SearchQueryBuilder {
user.HumanPasswordChangedType, user.HumanPasswordChangedType,
user.UserV1PasswordChangedType, user.UserV1PasswordChangedType,
user.UserIDPLinkAddedType, user.UserIDPLinkAddedType,
user.UserIDPLinkRemovedType,
user.UserIDPLinkCascadeRemovedType,
user.HumanPasswordlessTokenVerifiedType, user.HumanPasswordlessTokenVerifiedType,
user.HumanPasswordlessTokenRemovedType,
).Builder() ).Builder()
if wm.ResourceOwner != "" { if wm.ResourceOwner != "" {
query.ResourceOwner(wm.ResourceOwner) query.ResourceOwner(wm.ResourceOwner)
+108
View File
@@ -742,6 +742,114 @@ func TestCommands_CreateInviteCode(t *testing.T) {
returnCode: gu.Ptr("code2"), returnCode: gu.Ptr("code2"),
}, },
}, },
{
"user already has an auth method, precondition failed",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"username", "firstName",
"lastName",
"nickName",
"displayName",
language.Afrikaans,
domain.GenderUnspecified,
"email",
false,
),
),
eventFromEventPusher(
user.NewUserIDPLinkAddedEvent(context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"idpConfigID", "displayName", "externalUserID",
),
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
defaultSecretGenerators: &SecretGenerators{},
},
args{
ctx: context.Background(),
invite: &CreateUserInvite{
UserID: "userID",
},
},
want{
err: zerrors.ThrowPreconditionFailed(nil, "COMMAND-EF34g", "Errors.User.AlreadyInitialised"),
},
},
{
"create ok after all auth methods removed",
fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"username", "firstName",
"lastName",
"nickName",
"displayName",
language.Afrikaans,
domain.GenderUnspecified,
"email",
false,
),
),
// user linked an IDP, then it was removed again
eventFromEventPusher(
user.NewUserIDPLinkAddedEvent(context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"idpConfigID", "displayName", "externalUserID",
),
),
eventFromEventPusher(
user.NewUserIDPLinkRemovedEvent(context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
"idpConfigID", "externalUserID",
),
),
),
expectPush(
eventFromEventPusher(
user.NewHumanInviteCodeAddedEvent(context.Background(),
&user.NewAggregate("userID", "org1").Aggregate,
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("code"),
},
time.Hour,
"",
false,
"",
"",
),
),
),
),
checkPermission: newMockPermissionCheckAllowed(),
newEncryptedCodeWithDefault: mockEncryptedCodeWithDefault("code", time.Hour),
defaultSecretGenerators: &SecretGenerators{},
},
args{
ctx: context.Background(),
invite: &CreateUserInvite{
UserID: "userID",
},
},
want{
details: &domain.ObjectDetails{
ResourceOwner: "org1",
ID: "userID",
},
returnCode: nil,
},
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {