Files
zitadel/internal/command/user_v2_passkey_test.go
8a61aa4f36 Merge commit from fork
* fix(command): enforce permission check when issuing passkey enrollment codes

addUserPasskeyCode - behind CreatePasskeyRegistrationLink via
AddUserPasskeyCode / AddUserPasskeyCodeURLTemplate / AddUserPasskeyCodeReturn -
issued a passkey enrollment code without any authorization check.

The RPC's auth annotation carries user.passkey.write with no org_field, so the
API interceptor only verifies the caller's permission in the org taken from the
caller-supplied x-zitadel-orgid header, never the org that owns the target user.
The gRPC handlers additionally pass an empty resourceOwner, and the write model
lookup is instance-wide, so the code was minted against the victim's aggregate
regardless of org. An org owner of one organization could therefore issue a
passkey enrollment code for a user in another organization, enroll an
attacker-controlled authenticator and take over the account.

Authorize against the target user's actual resource owner, resolved from the
write model, before pushing the code requested event.

The check uses user.passkey.write - the permission the RPC already declares -
rather than user.credential.write used by the sibling RegisterUserPasskey. This
narrows the organization the permission is evaluated in without narrowing the
set of roles that may call the endpoint: IAM_USER_MANAGER holds
user.passkey.write but not user.credential.write, and would otherwise lose the
endpoint instance-wide.

The enrollment code stays a bearer credential by design, so the unauthenticated
login flows redeeming it are unaffected; issuance is the only place this can be
enforced.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(command): guard cross-org passkey enrollment code issuance

The existing AddUserPasskeyCode* tests all use an allowed permission check, so
none of them fails if the authorization is removed again.

Assert the denial path instead: with the caller passing an empty resourceOwner,
as the v2 gRPC handlers do, the check must receive the target user's real
resource owner resolved from the write model, and no event may be pushed.

Without the check in addUserPasskeyCode all three subtests fail, one of them on
an unexpected Push - a code would actually have been issued.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(user/v2): simulate cross-org passkey registration link attack

End-to-end reproduction against a live instance: an org owner of one
organization requests a passkey registration link, with the request-header org
pinned to its own org, for a user that lives in another organization. Uses the
return-code medium so a successful response would carry the plaintext code,
making a missing denial impossible to pass silently.

The second subtest pins the other half of the contract: IAM_USER_MANAGER holds
user.passkey.write instance-wide and must keep working across organizations, so
the fix cannot be tightened into user.credential.write without regressing it.

No equivalent test for user/v2beta: it shares the same internal/command layer
and is fixed by the same change, but the API is deprecated, so the duplicate is
deliberately omitted.

Co-Authored-By: Claude Opus 5 <[email protected]>

* refactor(command): generate passkey code only after the permission check

Review feedback: the code ID and the passkey code - the latter costs an extra
eventstore filter for the secret generator config - were produced before the
permission check, so an unauthorized caller still paid for both.

The write model is now built with an empty code ID, which is equivalent for
the lookup: the ID only matches events of an already existing code, and a code
about to be created has none. It is assigned right after the check so the
pushed event is still appended to the write model.

Co-Authored-By: Claude Opus 5 <[email protected]>

* fix(command): enforce permission check on v1 passwordless init codes

The deprecated management RPCs AddPasswordlessRegistration and
SendPasswordlessRegistration accept an attacker-controlled user_id and pass the
caller's own organization to the command. That organization only scopes the
write model's read, not the write: for a user of another organization the read
matches nothing, the write model keeps the caller's organization, and on push
the eventstore re-owns the event to the target's real organization
(internal/eventstore/v3/sequence.go). An org admin could therefore mint a
passwordless enrollment link for a user of any other organization and, after
correcting the orgID in the returned link, take the account over.

Same class of flaw as the v2 CreatePasskeyRegistrationLink issue, but a
different command, so the earlier fix does not reach it. Copying that fix
verbatim would not close it either: authorizing against the write model's
resource owner would authorize against the attacker's own organization, where
they legitimately hold the permission. The check is instead handed an empty
resource owner, which makes it resolve the target's real owner from the
eventstore.

The check sits in the two exported commands rather than in the shared inner
function, for two reasons:

  - the RPCs declare different permissions, user.credential.write and
    user.write. Hardcoding one would either lock IAM_USER_MANAGER and
    ORG_USER_MANAGER out of SendPasswordlessRegistration or widen
    AddPasswordlessRegistration to them. Access is preserved exactly.
  - the inner function is also called by importHuman while creating a user,
    whose aggregate has no events yet and whose owner cannot be resolved.

Self management stays allowed, so the auth API's AddMyPasswordlessLink and
SendMyPasswordlessLink are unaffected, as is anonymous redemption of the code
through the login UI - the init code is a bearer credential by design and
issuance is the only place this can be enforced.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(command): guard cross-org v1 passwordless init code issuance

Asserts the organization handed to checkPermission, not merely that a denial
happened. A caller acting in org1 targeting a user of org2 must be authorized
against org2; a test that only asserted PermissionDenied would pass against the
attacker's own organization, where they hold the permission - which is exactly
how a fix copied from the v2 command would fail.

Also pins the permission per command, so a later attempt to hoist the check
into the shared inner function shows up as roles gaining or losing access, and
asserts that self management still skips the check for the auth API.

Negative controls, both confirmed:
  - authorizing against the caller-supplied resource owner: the two RPC
    subtests fail on org1 != org2.
  - dropping the check: they fail on an unexpected ID generation and an
    unfulfilled resource owner lookup, i.e. a code would have been issued.

Co-Authored-By: Claude Opus 5 <[email protected]>

* test(management): simulate cross-org passwordless registration link attack

Reproduces the attack against a live instance through the deprecated v1
management API, and pins the two access guarantees the fix has to keep.

Verified by running the three builds against a real instance:

  | build                          | cross-org denied | IAM_USER_MANAGER can send |
  |--------------------------------|------------------|---------------------------|
  | no check                       | FAIL, link with  | PASS                      |
  |                                | plaintext code   |                           |
  |                                | returned         |                           |
  | user.credential.write for both | PASS             | FAIL, AUTH-AWfge          |
  | per-RPC permissions (shipped)  | PASS             | PASS                      |

Row 1 is the vulnerability. Row 2 is why SendPasswordlessRegistration keeps
user.write: IAM_USER_MANAGER holds it instance wide, and instance memberships
resolve in every organization, so the stricter permission would take the
endpoint away from that role everywhere.

ORG_USER_MANAGER gets no separate case: it holds the same user.write through an
org-scoped membership, which the same-org subtest already covers. The auth API's
self-service equivalents have no integration test suite; they are covered by the
self-management unit test instead.

Co-Authored-By: Claude Opus 5 <[email protected]>

* chore: trigger checks

Co-Authored-By: Claude Opus 5 <[email protected]>

---------

Co-authored-by: Claude Opus 5 <[email protected]>
2026-08-14 06:11:39 +02:00

1133 lines
31 KiB
Go

package command
import (
"context"
"io"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/api/authz"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/id"
id_mock "github.com/zitadel/zitadel/internal/id/mock"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/user"
webauthn_helper "github.com/zitadel/zitadel/internal/webauthn"
"github.com/zitadel/zitadel/internal/zerrors"
)
func TestCommands_RegisterUserPasskey(t *testing.T) {
ctx := authz.NewMockContextWithPermissions("instance1", "org1", "user1", nil)
ctx = http_util.WithRequestedHost(ctx, "example.com")
webauthnConfig := &webauthn_helper.Config{
DisplayName: "test",
ExternalSecure: true,
}
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
eventstore func(t *testing.T) *eventstore.Eventstore
idGenerator id.Generator
checkPermission domain.PermissionCheck
}
type args struct {
userID string
resourceOwner string
rpID string
authenticator domain.AuthenticatorAttachment
}
tests := []struct {
name string
fields fields
args args
want *domain.WebAuthNRegistrationDetails
wantErr error
}{
{
name: "no permission",
fields: fields{
eventstore: expectEventstore(),
checkPermission: newMockPermissionCheckNotAllowed(),
},
args: args{
userID: "foo",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
},
wantErr: zerrors.ThrowPermissionDenied(nil, "AUTHZ-HKJD33", "Errors.PermissionDenied"),
},
{
name: "get human passwordless error",
fields: fields{
eventstore: expectEventstore(
expectFilterError(io.ErrClosedPipe),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
},
wantErr: io.ErrClosedPipe,
},
{
name: "id generator error",
fields: fields{
eventstore: expectEventstore(
expectFilter(), // getHumanPasswordlessTokens
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectFilter(eventFromEventPusher(
org.NewOrgAddedEvent(ctx,
&org.NewAggregate("org1").Aggregate,
"org1",
),
)),
expectFilter(eventFromEventPusher(
org.NewDomainPolicyAddedEvent(ctx,
&org.NewAggregate("org1").Aggregate,
false, false, false,
),
)),
),
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
},
args: args{
userID: "user1",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
},
wantErr: io.ErrClosedPipe,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
webauthnConfig: webauthnConfig,
checkPermission: tt.fields.checkPermission,
}
_, err := c.RegisterUserPasskey(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.rpID, tt.args.authenticator)
require.ErrorIs(t, err, tt.wantErr)
// successful case can't be tested due to random challenge.
})
}
}
func TestCommands_RegisterUserPasskeyWithCode(t *testing.T) {
ctx := http_util.WithRequestedHost(context.Background(), "example.com")
webauthnConfig := &webauthn_helper.Config{
DisplayName: "test",
ExternalSecure: true,
}
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
es := expectEventstore(
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
)(t)
code, err := newEncryptedCode(ctx, es.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg) //nolint:staticcheck
require.NoError(t, err)
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
userID string
resourceOwner string
rpID string
authenticator domain.AuthenticatorAttachment
codeID string
code string
}
tests := []struct {
name string
fields fields
args args
wantErr error
}{
{
name: "code verification error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
),
),
expectPush(
user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"),
),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
codeID: "123",
code: "wrong",
},
wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid"),
},
{
name: "code verification ok, get human passwordless error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
),
),
expectFilterError(io.ErrClosedPipe),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
codeID: "123",
code: code.Plain,
},
wantErr: io.ErrClosedPipe,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
webauthnConfig: webauthnConfig,
}
_, err := c.RegisterUserPasskeyWithCode(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.authenticator, tt.args.codeID, tt.args.code, tt.args.rpID, alg)
require.ErrorIs(t, err, tt.wantErr)
// successful case can't be tested due to random challenge.
})
}
}
func TestCommands_verifyUserPasskeyCode(t *testing.T) {
ctx := http_util.WithRequestedHost(context.Background(), "example.com")
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
es := expectEventstore(
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
)(t)
code, err := newEncryptedCode(ctx, es.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg) //nolint:staticcheck
require.NoError(t, err)
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
}
type args struct {
userID string
resourceOwner string
codeID string
code string
}
tests := []struct {
name string
fields fields
args args
want *user.HumanPasswordlessInitCodeCheckSucceededEvent
wantErr error
}{
{
name: "filter error",
fields: fields{
eventstore: expectEventstore(
expectFilterError(io.ErrClosedPipe),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
codeID: "123",
},
wantErr: io.ErrClosedPipe,
},
{
name: "code verification error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
),
),
expectPush(
user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"),
),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
codeID: "123",
code: "wrong",
},
wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid"),
},
{
name: "success",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
),
),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
codeID: "123",
code: code.Plain,
},
want: user.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, "123"),
},
{
name: "expired error",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusherWithCreationDate(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
time.Now().Add(-2*time.Minute),
),
eventFromEventPusherWithCreationDate(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
time.Now().Add(-2*time.Minute),
),
),
expectPush(
user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"),
),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
codeID: "123",
code: code.Plain,
},
wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid"),
},
{
// https://github.com/zitadel/zitadel/security/advisories/GHSA-2x66-r53r-9r86
name: "expired, fail, check again",
fields: fields{
eventstore: expectEventstore(
expectFilter(
eventFromEventPusherWithCreationDate(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg, "123", code.Crypted, time.Minute, "", false,
),
time.Now().Add(-2*time.Minute),
),
eventFromEventPusherWithCreationDate(
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
time.Now().Add(-2*time.Minute),
),
eventFromEventPusherWithCreationDateNow(
user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"),
),
),
expectPush(
user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"),
),
),
},
args: args{
userID: "user1",
resourceOwner: "org1",
codeID: "123",
code: code.Plain,
},
wantErr: zerrors.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
}
got, err := c.verifyUserPasskeyCode(ctx, tt.args.userID, tt.args.resourceOwner, tt.args.codeID, tt.args.code, alg)
require.ErrorIs(t, err, tt.wantErr)
if tt.wantErr == nil {
assert.Equal(t, tt.want, got(ctx, userAgg))
}
})
}
}
func TestCommands_pushUserPasskey(t *testing.T) {
ctx := http_util.WithRequestedHost(authz.NewMockContext("instance1", "org1", "user1"), "example.com")
webauthnConfig := &webauthn_helper.Config{
DisplayName: "test",
ExternalSecure: true,
}
userAgg := &user.NewAggregate("user1", "org1").Aggregate
prep := []expect{
expectFilter(), // getHumanPasswordlessTokens
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(ctx,
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectFilter(eventFromEventPusher(
org.NewOrgAddedEvent(ctx,
&org.NewAggregate("org1").Aggregate,
"org1",
),
)),
expectFilter(eventFromEventPusher(
org.NewDomainPolicyAddedEvent(ctx,
&org.NewAggregate("org1").Aggregate,
false, false, false,
),
)),
expectFilter(eventFromEventPusher(
user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush(
ctx, &org.NewAggregate("org1").Aggregate, user.HumanPasswordlessTokenAddedType,
), "111", "challenge", "rpID"),
)),
}
type args struct {
events []eventCallback
}
tests := []struct {
name string
expectPush func(challenge string) expect
args args
wantErr error
}{
{
name: "push error",
expectPush: func(challenge string) expect {
return expectPushFailed(io.ErrClosedPipe,
user.NewHumanPasswordlessAddedEvent(ctx,
userAgg, "123", challenge, "rpID",
),
)
},
args: args{},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
expectPush: func(challenge string) expect {
return expectPush(
user.NewHumanPasswordlessAddedEvent(ctx,
userAgg, "123", challenge, "rpID",
),
)
},
args: args{},
},
{
name: "initcode succeeded event",
expectPush: func(challenge string) expect {
return expectPush(
user.NewHumanPasswordlessAddedEvent(ctx,
userAgg, "123", challenge, "rpID",
),
user.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, "123"),
)
},
args: args{
events: []eventCallback{func(ctx context.Context, userAgg *eventstore.Aggregate) eventstore.Command {
return user.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, "123")
}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: expectEventstore(prep...)(t),
webauthnConfig: webauthnConfig,
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
}
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, "user1", "org1", "rpID", domain.AuthenticatorAttachmentCrossPlattform)
require.NoError(t, err)
c.eventstore = expectEventstore(tt.expectPush(webAuthN.Challenge))(t)
got, err := c.pushUserPasskey(ctx, wm, userAgg, webAuthN, tt.args.events...)
require.ErrorIs(t, err, tt.wantErr)
if tt.wantErr == nil {
assert.NotEmpty(t, got.PublicKeyCredentialCreationOptions)
assert.Equal(t, "123", got.ID)
assert.Equal(t, "org1", got.ObjectDetails.ResourceOwner)
}
})
}
}
func TestCommands_AddUserPasskeyCode(t *testing.T) {
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
newCode encrypedCodeFunc
eventstore func(t *testing.T) *eventstore.Eventstore
idGenerator id.Generator
loginPaths func(*testing.T) LoginPaths
}
type args struct {
userID string
resourceOwner string
}
tests := []struct {
name string
fields fields
args args
want *domain.ObjectDetails
wantErr error
}{
{
name: "id generator error",
fields: fields{
newCode: mockEncryptedCode("passkey1", time.Hour),
eventstore: expectEventstore(expectFilter()), // init code write model
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
loginPaths: expectLoginPathsNoCall,
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
fields: fields{
newCode: mockEncryptedCode("passkey1", time.Minute),
eventstore: expectEventstore(
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectPush(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "", false,
),
),
),
loginPaths: expectLoginPathsDefaultPasskeySetURLTemplate(""),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
{
name: "success, url template",
fields: fields{
newCode: mockEncryptedCode("passkey1", time.Minute),
eventstore: expectEventstore(
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectPush(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "http://example.com/{{.user}}/email/{{.code}}", false,
),
),
),
loginPaths: expectLoginPathsDefaultPasskeySetURLTemplate("http://example.com/{{.user}}/email/{{.code}}"),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: newMockPermissionCheckAllowed(),
newEncryptedCode: tt.fields.newCode,
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
loginPaths: tt.fields.loginPaths(t),
}
got, err := c.AddUserPasskeyCode(context.Background(), tt.args.userID, tt.args.resourceOwner, alg)
require.ErrorIs(t, err, tt.wantErr)
assertObjectDetails(t, tt.want, got)
})
}
}
func TestCommands_AddUserPasskeyCodeURLTemplate(t *testing.T) {
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
newCode encrypedCodeFunc
eventstore func(t *testing.T) *eventstore.Eventstore
idGenerator id.Generator
}
type args struct {
userID string
resourceOwner string
urlTmpl string
}
tests := []struct {
name string
fields fields
args args
want *domain.ObjectDetails
wantErr error
}{
{
name: "template error",
fields: fields{
newCode: newEncryptedCode,
eventstore: expectEventstore(),
},
args: args{
userID: "user1",
resourceOwner: "org1",
urlTmpl: "{{",
},
wantErr: zerrors.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
},
{
name: "id generator error",
fields: fields{
newCode: newEncryptedCode,
eventstore: expectEventstore(expectFilter()), // init code write model
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
},
args: args{
userID: "user1",
resourceOwner: "org1",
urlTmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
fields: fields{
newCode: mockEncryptedCode("passkey1", time.Minute),
eventstore: expectEventstore(
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectPush(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
},
time.Minute,
"https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
false,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
urlTmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
},
want: &domain.ObjectDetails{
ResourceOwner: "org1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: newMockPermissionCheckAllowed(),
newEncryptedCode: tt.fields.newCode,
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
}
got, err := c.AddUserPasskeyCodeURLTemplate(context.Background(), tt.args.userID, tt.args.resourceOwner, alg, tt.args.urlTmpl)
require.ErrorIs(t, err, tt.wantErr)
assertObjectDetails(t, tt.want, got)
})
}
}
func TestCommands_AddUserPasskeyCodeReturn(t *testing.T) {
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
newCode encrypedCodeFunc
eventstore func(t *testing.T) *eventstore.Eventstore
idGenerator id.Generator
loginPaths func(*testing.T) LoginPaths
}
type args struct {
userID string
resourceOwner string
}
tests := []struct {
name string
fields fields
args args
want *domain.PasskeyCodeDetails
wantErr error
}{
{
name: "id generator error",
fields: fields{
newCode: newEncryptedCode,
eventstore: expectEventstore(expectFilter()), // init code write model
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
loginPaths: expectLoginPathsNoCall,
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
fields: fields{
newCode: mockEncryptedCode("passkey1", time.Minute),
eventstore: expectEventstore(
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectPush(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "", true,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
loginPaths: expectLoginPathsDefaultPasskeySetURLTemplate(""),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
want: &domain.PasskeyCodeDetails{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
CodeID: "123",
Code: "passkey1",
},
},
{
name: "success, url template",
fields: fields{
newCode: mockEncryptedCode("passkey1", time.Minute),
eventstore: expectEventstore(
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectPush(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "http://example.com/{{.user}}/email/{{.code}}", true,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
loginPaths: expectLoginPathsDefaultPasskeySetURLTemplate("http://example.com/{{.user}}/email/{{.code}}"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
want: &domain.PasskeyCodeDetails{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
CodeID: "123",
Code: "passkey1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: newMockPermissionCheckAllowed(),
newEncryptedCode: tt.fields.newCode,
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
loginPaths: tt.fields.loginPaths(t),
}
got, err := c.AddUserPasskeyCodeReturn(context.Background(), tt.args.userID, tt.args.resourceOwner, alg)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
assert.Equal(t, tt.want.CodeID, got.CodeID)
assert.Equal(t, tt.want.Code, got.Code)
assertObjectDetails(t, tt.want.ObjectDetails, got.ObjectDetails)
})
}
}
func TestCommands_addUserPasskeyCode(t *testing.T) {
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
userAgg := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
newCode encrypedCodeFunc
eventstore func(t *testing.T) *eventstore.Eventstore
idGenerator id.Generator
loginPaths func(*testing.T) LoginPaths
}
type args struct {
userID string
resourceOwner string
}
tests := []struct {
name string
fields fields
args args
want *domain.PasskeyCodeDetails
wantErr error
}{
{
name: "id generator error",
fields: fields{
newCode: newEncryptedCode,
eventstore: expectEventstore(expectFilter()), // init code write model
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
loginPaths: expectLoginPathsNoCall,
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "crypto error",
fields: fields{
newCode: newEncryptedCode,
eventstore: expectEventstore(
expectFilter(), // init code write model
expectFilterError(io.ErrClosedPipe),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
loginPaths: expectLoginPathsNoCall,
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "filter query error",
fields: fields{
newCode: newEncryptedCode,
eventstore: expectEventstore(
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
expectFilterError(io.ErrClosedPipe),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
loginPaths: expectLoginPathsNoCall,
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "push error",
fields: fields{
newCode: mockEncryptedCode("passkey1", time.Minute),
eventstore: expectEventstore(
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectPushFailed(io.ErrClosedPipe,
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
&user.NewAggregate("user1", "org1").Aggregate,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "", false,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
loginPaths: expectLoginPathsDefaultPasskeySetURLTemplate(""),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
fields: fields{
newCode: mockEncryptedCode("passkey1", time.Minute),
eventstore: expectEventstore(
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectPush(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "", false,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
loginPaths: expectLoginPathsDefaultPasskeySetURLTemplate(""),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
want: &domain.PasskeyCodeDetails{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
CodeID: "123",
Code: "passkey1",
},
}, {
name: "success, url template",
fields: fields{
newCode: mockEncryptedCode("passkey1", time.Minute),
eventstore: expectEventstore(
expectFilter(eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAgg,
"username",
"firstname",
"lastname",
"nickname",
"displayname",
language.German,
domain.GenderUnspecified,
"[email protected]",
true,
),
)),
expectPush(
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
userAgg,
"123", &crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("passkey1"),
}, time.Minute, "http://example.com/{{.user}}/email/{{.code}}", false,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
loginPaths: expectLoginPathsDefaultPasskeySetURLTemplate("http://example.com/{{.user}}/email/{{.code}}"),
},
args: args{
userID: "user1",
resourceOwner: "org1",
},
want: &domain.PasskeyCodeDetails{
ObjectDetails: &domain.ObjectDetails{
ResourceOwner: "org1",
},
CodeID: "123",
Code: "passkey1",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
checkPermission: newMockPermissionCheckAllowed(),
newEncryptedCode: tt.fields.newCode,
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
loginPaths: tt.fields.loginPaths(t),
}
got, err := c.addUserPasskeyCode(context.Background(), tt.args.userID, tt.args.resourceOwner, alg, "", false)
if tt.wantErr != nil {
require.ErrorIs(t, err, tt.wantErr)
return
}
assert.Equal(t, tt.want.CodeID, got.CodeID)
assert.Equal(t, tt.want.Code, got.Code)
assertObjectDetails(t, tt.want.ObjectDetails, got.ObjectDetails)
})
}
}