mirror of
https://github.com/zitadel/zitadel.git
synced 2024-12-13 11:34:26 +00:00
Merge branch 'next' into rc
This commit is contained in:
commit
c39193b1ed
@ -735,6 +735,7 @@ InternalAuthZ:
|
||||
- "user.grant.delete"
|
||||
- "user.membership.read"
|
||||
- "user.credential.write"
|
||||
- "user.passkey.write"
|
||||
- "policy.read"
|
||||
- "policy.write"
|
||||
- "policy.delete"
|
||||
@ -811,6 +812,7 @@ InternalAuthZ:
|
||||
- "user.grant.delete"
|
||||
- "user.membership.read"
|
||||
- "user.credential.write"
|
||||
- "user.passkey.write"
|
||||
- "policy.read"
|
||||
- "policy.write"
|
||||
- "policy.delete"
|
||||
@ -847,6 +849,7 @@ InternalAuthZ:
|
||||
- "user.grant.write"
|
||||
- "user.grant.delete"
|
||||
- "user.membership.read"
|
||||
- "user.passkey.write"
|
||||
- "project.read"
|
||||
- "project.member.read"
|
||||
- "project.role.read"
|
||||
@ -882,6 +885,7 @@ InternalAuthZ:
|
||||
- "user.grant.delete"
|
||||
- "user.membership.read"
|
||||
- "user.credential.write"
|
||||
- "user.passkey.write"
|
||||
- "policy.read"
|
||||
- "policy.write"
|
||||
- "policy.delete"
|
||||
|
12
go.mod
12
go.mod
@ -15,12 +15,13 @@ require (
|
||||
github.com/benbjohnson/clock v1.3.0
|
||||
github.com/boombuler/barcode v1.0.1
|
||||
github.com/cockroachdb/cockroach-go/v2 v2.3.3
|
||||
github.com/descope/virtualwebauthn v1.0.2
|
||||
github.com/dop251/goja v0.0.0-20230402114112-623f9dda9079
|
||||
github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124
|
||||
github.com/drone/envsubst v1.0.3
|
||||
github.com/duo-labs/webauthn v0.0.0-20221205164246-ebaf9b74c6ec
|
||||
github.com/envoyproxy/protoc-gen-validate v0.10.1
|
||||
github.com/go-ldap/ldap/v3 v3.4.4
|
||||
github.com/go-webauthn/webauthn v0.8.2
|
||||
github.com/golang/glog v1.1.1
|
||||
github.com/golang/mock v1.6.0
|
||||
github.com/golang/protobuf v1.5.3
|
||||
@ -70,7 +71,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.14.0
|
||||
go.opentelemetry.io/otel/sdk/metric v0.37.0
|
||||
go.opentelemetry.io/otel/trace v1.14.0
|
||||
golang.org/x/crypto v0.7.0
|
||||
golang.org/x/crypto v0.9.0
|
||||
golang.org/x/net v0.10.0
|
||||
golang.org/x/oauth2 v0.8.0
|
||||
golang.org/x/sync v0.1.0
|
||||
@ -86,12 +87,16 @@ require (
|
||||
|
||||
require (
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.37.0 // indirect
|
||||
github.com/cloudflare/cfssl v1.6.3 // indirect
|
||||
github.com/go-logr/logr v1.2.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||
github.com/go-webauthn/revoke v0.1.9 // indirect
|
||||
github.com/google/go-tpm v0.3.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20230323073829-e72429f035bd // indirect
|
||||
github.com/gorilla/websocket v1.4.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.7 // indirect
|
||||
github.com/smartystreets/assertions v1.0.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.14.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
)
|
||||
@ -136,7 +141,6 @@ require (
|
||||
github.com/golang/geo v0.0.0-20230404232722-c4acd7a044dc // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/snappy v0.0.4 // indirect
|
||||
github.com/google/certificate-transparency-go v1.1.4 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect
|
||||
|
16
internal/api/authz/user.go
Normal file
16
internal/api/authz/user.go
Normal file
@ -0,0 +1,16 @@
|
||||
package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
// UserIDInCTX checks if the userID
|
||||
// equals the authenticated user in the context.
|
||||
func UserIDInCTX(ctx context.Context, userID string) error {
|
||||
if GetCtxData(ctx).UserID != userID {
|
||||
return errors.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong")
|
||||
}
|
||||
return nil
|
||||
}
|
@ -11,38 +11,13 @@ import (
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/grpc"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
|
||||
)
|
||||
|
||||
var ignoreMessageTypes = map[protoreflect.FullName]bool{
|
||||
"google.protobuf.Duration": true,
|
||||
}
|
||||
|
||||
// allFieldsSet recusively checks if all values in a message
|
||||
// have a non-zero value.
|
||||
func allFieldsSet(t testing.TB, msg protoreflect.Message) {
|
||||
md := msg.Descriptor()
|
||||
name := md.FullName()
|
||||
if ignoreMessageTypes[name] {
|
||||
return
|
||||
}
|
||||
|
||||
fields := md.Fields()
|
||||
|
||||
for i := 0; i < fields.Len(); i++ {
|
||||
fd := fields.Get(i)
|
||||
if !msg.Has(fd) {
|
||||
t.Errorf("not all fields set in %q, missing %q", name, fd.Name())
|
||||
continue
|
||||
}
|
||||
|
||||
if fd.Kind() == protoreflect.MessageKind {
|
||||
allFieldsSet(t, msg.Get(fd).Message())
|
||||
}
|
||||
}
|
||||
}
|
||||
var ignoreTypes = []protoreflect.FullName{"google.protobuf.Duration"}
|
||||
|
||||
func Test_loginSettingsToPb(t *testing.T) {
|
||||
arg := &query.LoginPolicy{
|
||||
@ -100,7 +75,7 @@ func Test_loginSettingsToPb(t *testing.T) {
|
||||
}
|
||||
|
||||
got := loginSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("loginSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
@ -241,7 +216,7 @@ func Test_passwordSettingsToPb(t *testing.T) {
|
||||
}
|
||||
|
||||
got := passwordSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("passwordSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
@ -295,7 +270,7 @@ func Test_brandingSettingsToPb(t *testing.T) {
|
||||
}
|
||||
|
||||
got := brandingSettingsToPb(arg, "http://example.com")
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("brandingSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
@ -315,7 +290,7 @@ func Test_domainSettingsToPb(t *testing.T) {
|
||||
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
}
|
||||
got := domainSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("domainSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
@ -337,7 +312,7 @@ func Test_legalSettingsToPb(t *testing.T) {
|
||||
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
}
|
||||
got := legalAndSupportSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("legalSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
@ -353,7 +328,7 @@ func Test_lockoutSettingsToPb(t *testing.T) {
|
||||
ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
|
||||
}
|
||||
got := lockoutSettingsToPb(arg)
|
||||
allFieldsSet(t, got.ProtoReflect())
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
|
||||
if !proto.Equal(got, want) {
|
||||
t.Errorf("lockoutSettingsToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
@ -387,7 +362,7 @@ func Test_identityProvidersToPb(t *testing.T) {
|
||||
got := identityProvidersToPb(arg)
|
||||
require.Len(t, got, len(got))
|
||||
for i, v := range got {
|
||||
allFieldsSet(t, v.ProtoReflect())
|
||||
grpc.AllFieldsSet(t, v.ProtoReflect(), ignoreTypes...)
|
||||
if !proto.Equal(v, want[i]) {
|
||||
t.Errorf("identityProvidersToPb() =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
|
104
internal/api/grpc/user/v2/passkey.go
Normal file
104
internal/api/grpc/user/v2/passkey.go
Normal file
@ -0,0 +1,104 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) {
|
||||
var (
|
||||
resourceOwner = authz.GetCtxData(ctx).ResourceOwner
|
||||
authenticator = passkeyAuthenticatorToDomain(req.GetAuthenticator())
|
||||
)
|
||||
if code := req.GetCode(); code != nil {
|
||||
return passkeyRegistrationDetailsToPb(
|
||||
s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), resourceOwner, authenticator, code.Id, code.Code, s.userCodeAlg),
|
||||
)
|
||||
}
|
||||
return passkeyRegistrationDetailsToPb(
|
||||
s.command.RegisterUserPasskey(ctx, req.GetUserId(), resourceOwner, authenticator),
|
||||
)
|
||||
}
|
||||
|
||||
func passkeyAuthenticatorToDomain(pa user.PasskeyAuthenticator) domain.AuthenticatorAttachment {
|
||||
switch pa {
|
||||
case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_UNSPECIFIED:
|
||||
return domain.AuthenticatorAttachmentUnspecified
|
||||
case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM:
|
||||
return domain.AuthenticatorAttachmentPlattform
|
||||
case user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM:
|
||||
return domain.AuthenticatorAttachmentCrossPlattform
|
||||
default:
|
||||
return domain.AuthenticatorAttachmentUnspecified
|
||||
}
|
||||
}
|
||||
|
||||
func passkeyRegistrationDetailsToPb(details *domain.PasskeyRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user.RegisterPasskeyResponse{
|
||||
Details: object.DomainToDetailsPb(details.ObjectDetails),
|
||||
PasskeyId: details.PasskeyID,
|
||||
PublicKeyCredentialCreationOptions: details.PublicKeyCredentialCreationOptions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) {
|
||||
resourceOwner := authz.GetCtxData(ctx).ResourceOwner
|
||||
objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), resourceOwner, req.GetPasskeyName(), "", req.GetPublicKeyCredential())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user.VerifyPasskeyRegistrationResponse{
|
||||
Details: object.DomainToDetailsPb(objectDetails),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *user.CreatePasskeyRegistrationLinkRequest) (resp *user.CreatePasskeyRegistrationLinkResponse, err error) {
|
||||
resourceOwner := authz.GetCtxData(ctx).ResourceOwner
|
||||
|
||||
switch medium := req.Medium.(type) {
|
||||
case nil:
|
||||
return passkeyDetailsToPb(
|
||||
s.command.AddUserPasskeyCode(ctx, req.GetUserId(), resourceOwner, s.userCodeAlg),
|
||||
)
|
||||
case *user.CreatePasskeyRegistrationLinkRequest_SendLink:
|
||||
return passkeyDetailsToPb(
|
||||
s.command.AddUserPasskeyCodeURLTemplate(ctx, req.GetUserId(), resourceOwner, s.userCodeAlg, medium.SendLink.GetUrlTemplate()),
|
||||
)
|
||||
case *user.CreatePasskeyRegistrationLinkRequest_ReturnCode:
|
||||
return passkeyCodeDetailsToPb(
|
||||
s.command.AddUserPasskeyCodeReturn(ctx, req.GetUserId(), resourceOwner, s.userCodeAlg),
|
||||
)
|
||||
default:
|
||||
return nil, caos_errs.ThrowUnimplementedf(nil, "USERv2-gaD8y", "verification oneOf %T in method CreatePasskeyRegistrationLink not implemented", medium)
|
||||
}
|
||||
}
|
||||
|
||||
func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user.CreatePasskeyRegistrationLinkResponse{
|
||||
Details: object.DomainToDetailsPb(details),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user.CreatePasskeyRegistrationLinkResponse{
|
||||
Details: object.DomainToDetailsPb(details.ObjectDetails),
|
||||
Code: &user.PasskeyRegistrationCode{
|
||||
Id: details.CodeID,
|
||||
Code: details.Code,
|
||||
},
|
||||
}, nil
|
||||
}
|
309
internal/api/grpc/user/v2/passkey_integration_test.go
Normal file
309
internal/api/grpc/user/v2/passkey_integration_test.go
Normal file
@ -0,0 +1,309 @@
|
||||
//go:build integration
|
||||
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/internal/webauthn"
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func TestServer_RegisterPasskey(t *testing.T) {
|
||||
userID := createHumanUser(t).GetUserId()
|
||||
reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
|
||||
UserId: userID,
|
||||
Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client := webauthn.NewClient(Tester.Config.WebAuthNName, Tester.Config.ExternalDomain, "https://"+Tester.Host())
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.RegisterPasskeyRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.RegisterPasskeyResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing user id",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterPasskeyRequest{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "register code",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterPasskeyRequest{
|
||||
UserId: userID,
|
||||
Code: reg.GetCode(),
|
||||
Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM,
|
||||
},
|
||||
},
|
||||
want: &user.RegisterPasskeyResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reuse code (not allowed)",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterPasskeyRequest{
|
||||
UserId: userID,
|
||||
Code: reg.GetCode(),
|
||||
Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong code",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterPasskeyRequest{
|
||||
UserId: userID,
|
||||
Code: &user.PasskeyRegistrationCode{
|
||||
Id: reg.GetCode().GetId(),
|
||||
Code: "foobar",
|
||||
},
|
||||
Authenticator: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "user mismatch",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterPasskeyRequest{
|
||||
UserId: userID,
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
/* TODO after we are able to obtain a Bearer token for a human user
|
||||
{
|
||||
name: "human user",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.RegisterPasskeyRequest{
|
||||
UserId: humanUserID,
|
||||
},
|
||||
},
|
||||
want: &user.RegisterPasskeyResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
*/
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Client.RegisterPasskey(tt.args.ctx, tt.args.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
integration.AssertDetails(t, tt.want, got)
|
||||
if tt.want != nil {
|
||||
assert.NotEmpty(t, got.GetPasskeyId())
|
||||
assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions())
|
||||
_, err := client.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_VerifyPasskeyRegistration(t *testing.T) {
|
||||
userID := createHumanUser(t).GetUserId()
|
||||
reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
|
||||
UserId: userID,
|
||||
Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
pkr, err := Client.RegisterPasskey(CTX, &user.RegisterPasskeyRequest{
|
||||
UserId: userID,
|
||||
Code: reg.GetCode(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, pkr.GetPasskeyId())
|
||||
require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
|
||||
|
||||
client := webauthn.NewClient(Tester.Config.WebAuthNName, Tester.Config.ExternalDomain, "https://"+Tester.Host())
|
||||
attestationResponse, err := client.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
|
||||
require.NoError(t, err)
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.VerifyPasskeyRegistrationRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.VerifyPasskeyRegistrationResponse
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing user id",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.VerifyPasskeyRegistrationRequest{
|
||||
PasskeyId: pkr.GetPasskeyId(),
|
||||
PublicKeyCredential: []byte(attestationResponse),
|
||||
PasskeyName: "nice name",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.VerifyPasskeyRegistrationRequest{
|
||||
UserId: userID,
|
||||
PasskeyId: pkr.GetPasskeyId(),
|
||||
PublicKeyCredential: attestationResponse,
|
||||
PasskeyName: "nice name",
|
||||
},
|
||||
},
|
||||
want: &user.VerifyPasskeyRegistrationResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong credential",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.VerifyPasskeyRegistrationRequest{
|
||||
UserId: userID,
|
||||
PasskeyId: pkr.GetPasskeyId(),
|
||||
PublicKeyCredential: []byte("attestationResponseattestationResponseattestationResponse"),
|
||||
PasskeyName: "nice name",
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Client.VerifyPasskeyRegistration(tt.args.ctx, tt.args.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
integration.AssertDetails(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_CreatePasskeyRegistrationLink(t *testing.T) {
|
||||
userID := createHumanUser(t).GetUserId()
|
||||
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
req *user.CreatePasskeyRegistrationLinkRequest
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.CreatePasskeyRegistrationLinkResponse
|
||||
wantCode bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing user id",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.CreatePasskeyRegistrationLinkRequest{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "send default mail",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.CreatePasskeyRegistrationLinkRequest{
|
||||
UserId: userID,
|
||||
},
|
||||
},
|
||||
want: &user.CreatePasskeyRegistrationLinkResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send custom url",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.CreatePasskeyRegistrationLinkRequest{
|
||||
UserId: userID,
|
||||
Medium: &user.CreatePasskeyRegistrationLinkRequest_SendLink{
|
||||
SendLink: &user.SendPasskeyRegistrationLink{
|
||||
UrlTemplate: gu.Ptr("https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &user.CreatePasskeyRegistrationLinkResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "return code",
|
||||
args: args{
|
||||
ctx: CTX,
|
||||
req: &user.CreatePasskeyRegistrationLinkRequest{
|
||||
UserId: userID,
|
||||
Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
|
||||
},
|
||||
},
|
||||
want: &user.CreatePasskeyRegistrationLinkResponse{
|
||||
Details: &object.Details{
|
||||
ResourceOwner: Tester.Organisation.ID,
|
||||
},
|
||||
},
|
||||
wantCode: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := Client.CreatePasskeyRegistrationLink(tt.args.ctx, tt.args.req)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
integration.AssertDetails(t, tt.want, got)
|
||||
if tt.wantCode {
|
||||
assert.NotEmpty(t, got.GetCode().GetId())
|
||||
assert.NotEmpty(t, got.GetCode().GetId())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
210
internal/api/grpc/user/v2/passkey_test.go
Normal file
210
internal/api/grpc/user/v2/passkey_test.go
Normal file
@ -0,0 +1,210 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/grpc"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
|
||||
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
|
||||
)
|
||||
|
||||
func Test_passkeyAuthenticatorToDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
pa user.PasskeyAuthenticator
|
||||
want domain.AuthenticatorAttachment
|
||||
}{
|
||||
{
|
||||
pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_UNSPECIFIED,
|
||||
want: domain.AuthenticatorAttachmentUnspecified,
|
||||
},
|
||||
{
|
||||
pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_PLATFORM,
|
||||
want: domain.AuthenticatorAttachmentPlattform,
|
||||
},
|
||||
{
|
||||
pa: user.PasskeyAuthenticator_PASSKEY_AUTHENTICATOR_CROSS_PLATFORM,
|
||||
want: domain.AuthenticatorAttachmentCrossPlattform,
|
||||
},
|
||||
{
|
||||
pa: 999,
|
||||
want: domain.AuthenticatorAttachmentUnspecified,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.pa.String(), func(t *testing.T) {
|
||||
got := passkeyAuthenticatorToDomain(tt.pa)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
|
||||
type args struct {
|
||||
details *domain.PasskeyRegistrationDetails
|
||||
err error
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.RegisterPasskeyResponse
|
||||
}{
|
||||
{
|
||||
name: "an error",
|
||||
args: args{
|
||||
details: nil,
|
||||
err: io.ErrClosedPipe,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
args: args{
|
||||
details: &domain.PasskeyRegistrationDetails{
|
||||
ObjectDetails: &domain.ObjectDetails{
|
||||
Sequence: 22,
|
||||
EventDate: time.Unix(3000, 22),
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
PasskeyID: "123",
|
||||
PublicKeyCredentialCreationOptions: []byte{1, 2, 3},
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
want: &user.RegisterPasskeyResponse{
|
||||
Details: &object.Details{
|
||||
Sequence: 22,
|
||||
ChangeDate: ×tamppb.Timestamp{
|
||||
Seconds: 3000,
|
||||
Nanos: 22,
|
||||
},
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
PasskeyId: "123",
|
||||
PublicKeyCredentialCreationOptions: []byte{1, 2, 3},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err)
|
||||
require.ErrorIs(t, err, tt.args.err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
if tt.want != nil {
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_passkeyDetailsToPb(t *testing.T) {
|
||||
type args struct {
|
||||
details *domain.ObjectDetails
|
||||
err error
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.CreatePasskeyRegistrationLinkResponse
|
||||
}{
|
||||
{
|
||||
name: "an error",
|
||||
args: args{
|
||||
details: nil,
|
||||
err: io.ErrClosedPipe,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
args: args{
|
||||
details: &domain.ObjectDetails{
|
||||
Sequence: 22,
|
||||
EventDate: time.Unix(3000, 22),
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
want: &user.CreatePasskeyRegistrationLinkResponse{
|
||||
Details: &object.Details{
|
||||
Sequence: 22,
|
||||
ChangeDate: ×tamppb.Timestamp{
|
||||
Seconds: 3000,
|
||||
Nanos: 22,
|
||||
},
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := passkeyDetailsToPb(tt.args.details, tt.args.err)
|
||||
require.ErrorIs(t, err, tt.args.err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_passkeyCodeDetailsToPb(t *testing.T) {
|
||||
type args struct {
|
||||
details *domain.PasskeyCodeDetails
|
||||
err error
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *user.CreatePasskeyRegistrationLinkResponse
|
||||
}{
|
||||
{
|
||||
name: "an error",
|
||||
args: args{
|
||||
details: nil,
|
||||
err: io.ErrClosedPipe,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok",
|
||||
args: args{
|
||||
details: &domain.PasskeyCodeDetails{
|
||||
ObjectDetails: &domain.ObjectDetails{
|
||||
Sequence: 22,
|
||||
EventDate: time.Unix(3000, 22),
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
CodeID: "123",
|
||||
Code: "456",
|
||||
},
|
||||
err: nil,
|
||||
},
|
||||
want: &user.CreatePasskeyRegistrationLinkResponse{
|
||||
Details: &object.Details{
|
||||
Sequence: 22,
|
||||
ChangeDate: ×tamppb.Timestamp{
|
||||
Seconds: 3000,
|
||||
Nanos: 22,
|
||||
},
|
||||
ResourceOwner: "me",
|
||||
},
|
||||
Code: &user.PasskeyRegistrationCode{
|
||||
Id: "123",
|
||||
Code: "456",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := passkeyCodeDetailsToPb(tt.args.details, tt.args.err)
|
||||
require.ErrorIs(t, err, tt.args.err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
if tt.want != nil {
|
||||
grpc.AllFieldsSet(t, got.ProtoReflect())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -32,7 +32,7 @@ type Commands struct {
|
||||
httpClient *http.Client
|
||||
|
||||
checkPermission domain.PermissionCheck
|
||||
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error)
|
||||
newCode cryptoCodeFunc
|
||||
|
||||
eventstore *eventstore.Eventstore
|
||||
static static.Storage
|
||||
@ -109,7 +109,7 @@ func StartCommands(
|
||||
webauthnConfig: webAuthN,
|
||||
httpClient: httpClient,
|
||||
checkPermission: permissionCheck,
|
||||
newEmailCode: newEmailCode,
|
||||
newCode: newCryptoCodeWithExpiry,
|
||||
sessionTokenCreator: sessionTokenCreator(idGenerator, sessionAlg),
|
||||
sessionTokenVerifier: sessionTokenVerifier,
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
type cryptoCodeFunc func(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCodeWithExpiry, error)
|
||||
|
||||
type CryptoCodeWithExpiry struct {
|
||||
Crypted *crypto.CryptoValue
|
||||
Plain string
|
||||
@ -17,42 +19,50 @@ type CryptoCodeWithExpiry struct {
|
||||
}
|
||||
|
||||
func newCryptoCodeWithExpiry(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCodeWithExpiry, error) {
|
||||
config, err := secretGeneratorConfig(ctx, filter, typ)
|
||||
gen, config, err := secretGenerator(ctx, filter, typ, alg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
code := new(CryptoCodeWithExpiry)
|
||||
switch a := alg.(type) {
|
||||
case crypto.HashAlgorithm:
|
||||
code.Crypted, code.Plain, err = crypto.NewCode(crypto.NewHashGenerator(*config, a))
|
||||
case crypto.EncryptionAlgorithm:
|
||||
code.Crypted, code.Plain, err = crypto.NewCode(crypto.NewEncryptionGenerator(*config, a))
|
||||
default:
|
||||
return nil, errors.ThrowInternal(nil, "COMMA-RreV6", "Errors.Internal")
|
||||
}
|
||||
crypted, plain, err := crypto.NewCode(gen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CryptoCodeWithExpiry{
|
||||
Crypted: crypted,
|
||||
Plain: plain,
|
||||
Expiry: config.Expiry,
|
||||
}, nil
|
||||
}
|
||||
|
||||
code.Expiry = config.Expiry
|
||||
return code, nil
|
||||
func verifyCryptoCode(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto, creation time.Time, expiry time.Duration, crypted *crypto.CryptoValue, plain string) error {
|
||||
gen, _, err := secretGenerator(ctx, filter, typ, alg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return crypto.VerifyCode(creation, expiry, crypted, plain, gen)
|
||||
}
|
||||
|
||||
func newCryptoCodeWithPlain(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (value *crypto.CryptoValue, plain string, err error) {
|
||||
config, err := secretGeneratorConfig(ctx, filter, typ)
|
||||
gen, _, err := secretGenerator(ctx, filter, typ, alg)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return crypto.NewCode(gen)
|
||||
}
|
||||
|
||||
func secretGenerator(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType, alg crypto.Crypto) (crypto.Generator, *crypto.GeneratorConfig, error) {
|
||||
config, err := secretGeneratorConfig(ctx, filter, typ)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
switch a := alg.(type) {
|
||||
case crypto.HashAlgorithm:
|
||||
return crypto.NewCode(crypto.NewHashGenerator(*config, a))
|
||||
return crypto.NewHashGenerator(*config, a), config, nil
|
||||
case crypto.EncryptionAlgorithm:
|
||||
return crypto.NewCode(crypto.NewEncryptionGenerator(*config, a))
|
||||
return crypto.NewEncryptionGenerator(*config, a), config, nil
|
||||
default:
|
||||
return nil, nil, errors.ThrowInternalf(nil, "COMMA-RreV6", "Errors.Internal unsupported crypto algorithm type %T", a)
|
||||
}
|
||||
|
||||
return nil, "", errors.ThrowInvalidArgument(nil, "V2-NGESt", "Errors.Internal")
|
||||
}
|
||||
|
||||
func secretGeneratorConfig(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType) (*crypto.GeneratorConfig, error) {
|
||||
|
242
internal/command/crypto_test.go
Normal file
242
internal/command/crypto_test.go
Normal file
@ -0,0 +1,242 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/command/preparation"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/repository/instance"
|
||||
)
|
||||
|
||||
func mockCode(code string, exp time.Duration) cryptoCodeFunc {
|
||||
return func(ctx context.Context, filter preparation.FilterToQueryReducer, _ domain.SecretGeneratorType, alg crypto.Crypto) (*CryptoCodeWithExpiry, error) {
|
||||
return &CryptoCodeWithExpiry{
|
||||
Crypted: &crypto.CryptoValue{
|
||||
CryptoType: crypto.TypeEncryption,
|
||||
Algorithm: "enc",
|
||||
KeyID: "id",
|
||||
Crypted: []byte(code),
|
||||
},
|
||||
Plain: code,
|
||||
Expiry: exp,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
testGeneratorConfig = crypto.GeneratorConfig{
|
||||
Length: 12,
|
||||
Expiry: 60000000000,
|
||||
IncludeLowerLetters: true,
|
||||
IncludeUpperLetters: true,
|
||||
IncludeDigits: true,
|
||||
IncludeSymbols: true,
|
||||
}
|
||||
)
|
||||
|
||||
func testSecretGeneratorAddedEvent(typ domain.SecretGeneratorType) *instance.SecretGeneratorAddedEvent {
|
||||
return instance.NewSecretGeneratorAddedEvent(context.Background(),
|
||||
&instance.NewAggregate("inst1").Aggregate, typ,
|
||||
testGeneratorConfig.Length,
|
||||
testGeneratorConfig.Expiry,
|
||||
testGeneratorConfig.IncludeLowerLetters,
|
||||
testGeneratorConfig.IncludeUpperLetters,
|
||||
testGeneratorConfig.IncludeDigits,
|
||||
testGeneratorConfig.IncludeSymbols,
|
||||
)
|
||||
}
|
||||
|
||||
func Test_newCryptoCode(t *testing.T) {
|
||||
type args struct {
|
||||
typ domain.SecretGeneratorType
|
||||
alg crypto.Crypto
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
eventstore *eventstore.Eventstore
|
||||
args args
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "filter config error",
|
||||
eventstore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)),
|
||||
args: args{
|
||||
typ: domain.SecretGeneratorTypeVerifyEmailCode,
|
||||
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
eventstore: eventstoreExpect(t, expectFilter(
|
||||
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
|
||||
)),
|
||||
args: args{
|
||||
typ: domain.SecretGeneratorTypeVerifyEmailCode,
|
||||
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := newCryptoCodeWithExpiry(context.Background(), tt.eventstore.Filter, tt.args.typ, tt.args.alg)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
if tt.wantErr == nil {
|
||||
require.NotNil(t, got)
|
||||
assert.NotNil(t, got.Crypted)
|
||||
assert.NotEmpty(t, got)
|
||||
assert.Equal(t, testGeneratorConfig.Expiry, got.Expiry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_verifyCryptoCode(t *testing.T) {
|
||||
es := eventstoreExpect(t, expectFilter(
|
||||
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
|
||||
))
|
||||
code, err := newCryptoCodeWithExpiry(context.Background(), es.Filter, domain.SecretGeneratorTypeVerifyEmailCode, crypto.CreateMockHashAlg(gomock.NewController(t)))
|
||||
require.NoError(t, err)
|
||||
|
||||
type args struct {
|
||||
typ domain.SecretGeneratorType
|
||||
alg crypto.Crypto
|
||||
expiry time.Duration
|
||||
crypted *crypto.CryptoValue
|
||||
plain string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
eventsore *eventstore.Eventstore
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "filter config error",
|
||||
eventsore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)),
|
||||
args: args{
|
||||
typ: domain.SecretGeneratorTypeVerifyEmailCode,
|
||||
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
expiry: code.Expiry,
|
||||
crypted: code.Crypted,
|
||||
plain: code.Plain,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
eventsore: eventstoreExpect(t, expectFilter(
|
||||
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
|
||||
)),
|
||||
args: args{
|
||||
typ: domain.SecretGeneratorTypeVerifyEmailCode,
|
||||
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
expiry: code.Expiry,
|
||||
crypted: code.Crypted,
|
||||
plain: code.Plain,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong plain",
|
||||
eventsore: eventstoreExpect(t, expectFilter(
|
||||
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
|
||||
)),
|
||||
args: args{
|
||||
typ: domain.SecretGeneratorTypeVerifyEmailCode,
|
||||
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
expiry: code.Expiry,
|
||||
crypted: code.Crypted,
|
||||
plain: "wrong",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := verifyCryptoCode(context.Background(), tt.eventsore.Filter, tt.args.typ, tt.args.alg, time.Now(), tt.args.expiry, tt.args.crypted, tt.args.plain)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_secretGenerator(t *testing.T) {
|
||||
type args struct {
|
||||
typ domain.SecretGeneratorType
|
||||
alg crypto.Crypto
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
eventsore *eventstore.Eventstore
|
||||
args args
|
||||
want crypto.Generator
|
||||
wantConf *crypto.GeneratorConfig
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "filter config error",
|
||||
eventsore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)),
|
||||
args: args{
|
||||
typ: domain.SecretGeneratorTypeVerifyEmailCode,
|
||||
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "hash generator",
|
||||
eventsore: eventstoreExpect(t, expectFilter(
|
||||
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
|
||||
)),
|
||||
args: args{
|
||||
typ: domain.SecretGeneratorTypeVerifyEmailCode,
|
||||
alg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
},
|
||||
want: crypto.NewHashGenerator(testGeneratorConfig, crypto.CreateMockHashAlg(gomock.NewController(t))),
|
||||
wantConf: &testGeneratorConfig,
|
||||
},
|
||||
{
|
||||
name: "encryption generator",
|
||||
eventsore: eventstoreExpect(t, expectFilter(
|
||||
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
|
||||
)),
|
||||
args: args{
|
||||
typ: domain.SecretGeneratorTypeVerifyEmailCode,
|
||||
alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
|
||||
},
|
||||
want: crypto.NewEncryptionGenerator(testGeneratorConfig, crypto.CreateMockEncryptionAlg(gomock.NewController(t))),
|
||||
wantConf: &testGeneratorConfig,
|
||||
},
|
||||
{
|
||||
name: "unsupported type",
|
||||
eventsore: eventstoreExpect(t, expectFilter(
|
||||
eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypeVerifyEmailCode)),
|
||||
)),
|
||||
args: args{
|
||||
typ: domain.SecretGeneratorTypeVerifyEmailCode,
|
||||
alg: nil,
|
||||
},
|
||||
wantErr: errors.ThrowInternalf(nil, "COMMA-RreV6", "Errors.Internal unsupported crypto algorithm type %T", nil),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, gotConf, err := secretGenerator(context.Background(), tt.eventsore.Filter, tt.args.typ, tt.args.alg)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.IsType(t, tt.want, got)
|
||||
assert.Equal(t, tt.wantConf, gotConf)
|
||||
})
|
||||
}
|
||||
}
|
@ -23,6 +23,6 @@ func (e *Email) Validate() error {
|
||||
return e.Address.Validate()
|
||||
}
|
||||
|
||||
func newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
|
||||
return newCryptoCodeWithExpiry(ctx, filter, domain.SecretGeneratorTypeVerifyEmailCode, alg)
|
||||
func (c *Commands) newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
|
||||
return c.newCode(ctx, filter, domain.SecretGeneratorTypeVerifyEmailCode, alg)
|
||||
}
|
||||
|
@ -31,7 +31,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
|
||||
idGenerator id.Generator
|
||||
userPasswordAlg crypto.HashAlgorithm
|
||||
codeAlg crypto.EncryptionAlgorithm
|
||||
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error)
|
||||
newCode cryptoCodeFunc
|
||||
}
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
@ -446,7 +446,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
|
||||
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
|
||||
newEmailCode: mockEmailCode("emailCode", time.Hour),
|
||||
newCode: mockCode("emailCode", time.Hour),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
@ -526,7 +526,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
|
||||
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
|
||||
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
|
||||
newEmailCode: mockEmailCode("emailCode", time.Hour),
|
||||
newCode: mockCode("emailCode", time.Hour),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
@ -1202,7 +1202,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
|
||||
userPasswordAlg: tt.fields.userPasswordAlg,
|
||||
userEncryption: tt.fields.codeAlg,
|
||||
idGenerator: tt.fields.idGenerator,
|
||||
newEmailCode: tt.fields.newEmailCode,
|
||||
newCode: tt.fields.newCode,
|
||||
}
|
||||
err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail)
|
||||
if tt.res.err == nil {
|
||||
@ -3992,18 +3992,3 @@ func TestAddHumanCommand(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mockEmailCode(code string, exp time.Duration) func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
|
||||
return func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
|
||||
return &CryptoCodeWithExpiry{
|
||||
Crypted: &crypto.CryptoValue{
|
||||
CryptoType: crypto.TypeEncryption,
|
||||
Algorithm: "enc",
|
||||
KeyID: "id",
|
||||
Crypted: []byte(code),
|
||||
},
|
||||
Plain: code,
|
||||
Expiry: exp,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
@ -539,7 +539,7 @@ func (c *Commands) humanAddPasswordlessInitCode(ctx context.Context, userID, res
|
||||
}
|
||||
if !direct {
|
||||
codeEventCreator = func(ctx context.Context, agg *eventstore.Aggregate, id string, cryptoCode *crypto.CryptoValue, exp time.Duration) eventstore.Command {
|
||||
return usr_repo.NewHumanPasswordlessInitCodeRequestedEvent(ctx, agg, id, cryptoCode, exp)
|
||||
return usr_repo.NewHumanPasswordlessInitCodeRequestedEvent(ctx, agg, id, cryptoCode, exp, "", false)
|
||||
}
|
||||
}
|
||||
codeEvent := codeEventCreator(ctx, UserAggregateFromWriteModel(&initCode.WriteModel), codeID, cryptoCode, passwordlessCodeGenerator.Expiry())
|
||||
|
@ -512,6 +512,9 @@ func (wm *HumanPasswordlessInitCodeWriteModel) appendRequestedEvent(e *user.Huma
|
||||
wm.CryptoCode = e.Code
|
||||
wm.Expiration = e.Expiry
|
||||
wm.State = domain.PasswordlessInitCodeStateRequested
|
||||
if e.CodeReturned {
|
||||
wm.State = domain.PasswordlessInitCodeStateActive
|
||||
}
|
||||
}
|
||||
|
||||
func (wm *HumanPasswordlessInitCodeWriteModel) appendCheckFailedEvent(e *user.HumanPasswordlessInitCodeCheckFailedEvent) {
|
||||
|
@ -199,7 +199,7 @@ func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) {
|
||||
email: "email-changed@test.ch",
|
||||
urlTmpl: "{{",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "permission missing",
|
||||
|
156
internal/command/user_v2_passkey.go
Normal file
156
internal/command/user_v2_passkey.go
Normal file
@ -0,0 +1,156 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/repository/user"
|
||||
)
|
||||
|
||||
// RegisterUserPasskey creates a passkey registration for the current authenticated user.
|
||||
// UserID, ussualy taken from the request is compaired against the user ID in the context.
|
||||
func (c *Commands) RegisterUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment) (*domain.PasskeyRegistrationDetails, error) {
|
||||
if err := authz.UserIDInCTX(ctx, userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.registerUserPasskey(ctx, userID, resourceOwner, authenticator)
|
||||
}
|
||||
|
||||
// RegisterUserPasskeyWithCode registers a new passkey for a unauthenticated user id.
|
||||
// The resource is protected by the code, identified by the codeID.
|
||||
func (c *Commands) RegisterUserPasskeyWithCode(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment, codeID, code string, alg crypto.EncryptionAlgorithm) (*domain.PasskeyRegistrationDetails, error) {
|
||||
event, err := c.verifyUserPasskeyCode(ctx, userID, resourceOwner, codeID, code, alg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.registerUserPasskey(ctx, userID, resourceOwner, authenticator, event)
|
||||
}
|
||||
|
||||
type eventCallback func(context.Context, *eventstore.Aggregate) eventstore.Command
|
||||
|
||||
// verifyUserPasskeyCode verifies a passkey code, identified by codeID and userID.
|
||||
// A code can only be used once.
|
||||
// Upon success an event callback is returned, which must be called after
|
||||
// all other events for the current request are created.
|
||||
// This prevent consuming a code when another error occurred after verification.
|
||||
func (c *Commands) verifyUserPasskeyCode(ctx context.Context, userID, resourceOwner, codeID, code string, alg crypto.EncryptionAlgorithm) (eventCallback, error) {
|
||||
wm := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
|
||||
err := c.eventstore.FilterToQueryReducer(ctx, wm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = verifyCryptoCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg, wm.ChangeDate, wm.Expiration, wm.CryptoCode, code)
|
||||
if err != nil || wm.State != domain.PasswordlessInitCodeStateActive {
|
||||
c.verifyUserPasskeyCodeFailed(ctx, wm)
|
||||
return nil, caos_errs.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid")
|
||||
}
|
||||
return func(ctx context.Context, userAgg *eventstore.Aggregate) eventstore.Command {
|
||||
return user.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, codeID)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Commands) verifyUserPasskeyCodeFailed(ctx context.Context, wm *HumanPasswordlessInitCodeWriteModel) {
|
||||
userAgg := UserAggregateFromWriteModel(&wm.WriteModel)
|
||||
_, err := c.eventstore.Push(ctx, user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, wm.CodeID))
|
||||
logging.WithFields("userID", userAgg.ID).OnError(err).Error("RegisterUserPasskeyWithCode push failed")
|
||||
}
|
||||
|
||||
func (c *Commands) registerUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment, events ...eventCallback) (*domain.PasskeyRegistrationDetails, error) {
|
||||
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, userID, resourceOwner, authenticator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.pushUserPasskey(ctx, wm, userAgg, webAuthN, events...)
|
||||
}
|
||||
|
||||
func (c *Commands) createUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
|
||||
passwordlessTokens, err := c.getHumanPasswordlessTokens(ctx, userID, resourceOwner)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return c.addHumanWebAuthN(ctx, userID, resourceOwner, false, passwordlessTokens, authenticator, domain.UserVerificationRequirementRequired)
|
||||
}
|
||||
|
||||
func (c *Commands) pushUserPasskey(ctx context.Context, wm *HumanWebAuthNWriteModel, userAgg *eventstore.Aggregate, webAuthN *domain.WebAuthNToken, events ...eventCallback) (*domain.PasskeyRegistrationDetails, error) {
|
||||
cmds := make([]eventstore.Command, len(events)+1)
|
||||
cmds[0] = user.NewHumanPasswordlessAddedEvent(ctx, userAgg, wm.WebauthNTokenID, webAuthN.Challenge)
|
||||
for i, event := range events {
|
||||
cmds[i+1] = event(ctx, userAgg)
|
||||
}
|
||||
|
||||
err := c.pushAppendAndReduce(ctx, wm, cmds...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain.PasskeyRegistrationDetails{
|
||||
ObjectDetails: writeModelToObjectDetails(&wm.WriteModel),
|
||||
PasskeyID: wm.WebauthNTokenID,
|
||||
PublicKeyCredentialCreationOptions: webAuthN.CredentialCreationData,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddUserPasskeyCode generates a Passkey code and sends an email
|
||||
// with the default generated URL (pointing to zitadel).
|
||||
func (c *Commands) AddUserPasskeyCode(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm) (*domain.ObjectDetails, error) {
|
||||
details, err := c.addUserPasskeyCode(ctx, userID, resourceOwner, alg, "", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return details.ObjectDetails, err
|
||||
}
|
||||
|
||||
// AddUserPasskeyCodeURLTemplate generates a Passkey code and sends an email
|
||||
// with the URL created from passed template string.
|
||||
// The template is executed as a test, before pushing to the eventstore.
|
||||
func (c *Commands) AddUserPasskeyCodeURLTemplate(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm, urlTmpl string) (*domain.ObjectDetails, error) {
|
||||
if err := domain.RenderPasskeyURLTemplate(io.Discard, urlTmpl, userID, resourceOwner, "codeID", "code"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details, err := c.addUserPasskeyCode(ctx, userID, resourceOwner, alg, urlTmpl, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return details.ObjectDetails, err
|
||||
}
|
||||
|
||||
// AddUserPasskeyCodeReturn generates and returns a Passkey code.
|
||||
// No email will be send to the user.
|
||||
func (c *Commands) AddUserPasskeyCodeReturn(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm) (*domain.PasskeyCodeDetails, error) {
|
||||
return c.addUserPasskeyCode(ctx, userID, resourceOwner, alg, "", true)
|
||||
}
|
||||
|
||||
func (c *Commands) addUserPasskeyCode(ctx context.Context, userID, resourceOwner string, alg crypto.EncryptionAlgorithm, urlTmpl string, returnCode bool) (*domain.PasskeyCodeDetails, error) {
|
||||
codeID, err := c.idGenerator.Next()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, err := c.newCode(ctx, c.eventstore.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wm := NewHumanPasswordlessInitCodeWriteModel(userID, codeID, resourceOwner)
|
||||
err = c.eventstore.FilterToQueryReducer(ctx, wm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agg := UserAggregateFromWriteModel(&wm.WriteModel)
|
||||
|
||||
cmd := user.NewHumanPasswordlessInitCodeRequestedEvent(ctx, agg, codeID, code.Crypted, code.Expiry, urlTmpl, returnCode)
|
||||
err = c.pushAppendAndReduce(ctx, wm, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &domain.PasskeyCodeDetails{
|
||||
ObjectDetails: writeModelToObjectDetails(&wm.WriteModel),
|
||||
CodeID: codeID,
|
||||
Code: code.Plain,
|
||||
}, nil
|
||||
}
|
915
internal/command/user_v2_passkey_test.go
Normal file
915
internal/command/user_v2_passkey_test.go
Normal file
@ -0,0 +1,915 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/eventstore"
|
||||
"github.com/zitadel/zitadel/internal/eventstore/repository"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestCommands_RegisterUserPasskey(t *testing.T) {
|
||||
ctx := authz.NewMockContextWithPermissions("instance1", "org1", "user1", nil)
|
||||
ctx = authz.WithRequestedDomain(ctx, "example.com")
|
||||
|
||||
webauthnConfig := &webauthn_helper.Config{
|
||||
DisplayName: "test",
|
||||
ExternalSecure: true,
|
||||
}
|
||||
userAgg := &user.NewAggregate("user1", "org1").Aggregate
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
idGenerator id.Generator
|
||||
}
|
||||
type args struct {
|
||||
userID string
|
||||
resourceOwner string
|
||||
authenticator domain.AuthenticatorAttachment
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
fields fields
|
||||
args args
|
||||
want *domain.PasskeyRegistrationDetails
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "wrong user",
|
||||
args: args{
|
||||
userID: "foo",
|
||||
resourceOwner: "org1",
|
||||
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
|
||||
},
|
||||
wantErr: caos_errs.ThrowUnauthenticated(nil, "AUTH-Bohd2", "Errors.User.UserIDWrong"),
|
||||
},
|
||||
{
|
||||
name: "get human passwordless error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilterError(io.ErrClosedPipe),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "id generator error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(), // getHumanPasswordlessTokens
|
||||
expectFilter(eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(ctx,
|
||||
userAgg,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
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,
|
||||
idGenerator: tt.fields.idGenerator,
|
||||
webauthnConfig: webauthnConfig,
|
||||
}
|
||||
_, err := c.RegisterUserPasskey(ctx, tt.args.userID, tt.args.resourceOwner, 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 := authz.WithRequestedDomain(context.Background(), "example.com")
|
||||
webauthnConfig := &webauthn_helper.Config{
|
||||
DisplayName: "test",
|
||||
ExternalSecure: true,
|
||||
}
|
||||
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
|
||||
es := eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
|
||||
)
|
||||
code, err := newCryptoCodeWithExpiry(ctx, es.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg)
|
||||
require.NoError(t, err)
|
||||
userAgg := &user.NewAggregate("user1", "org1").Aggregate
|
||||
type fields struct {
|
||||
eventstore *eventstore.Eventstore
|
||||
idGenerator id.Generator
|
||||
}
|
||||
type args struct {
|
||||
userID string
|
||||
resourceOwner 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: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
|
||||
userAgg, "123", code.Crypted, time.Minute, "", false,
|
||||
),
|
||||
),
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
|
||||
),
|
||||
),
|
||||
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
|
||||
expectPush([]*repository.Event{eventFromEventPusher(
|
||||
user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"),
|
||||
)}),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
authenticator: domain.AuthenticatorAttachmentCrossPlattform,
|
||||
codeID: "123",
|
||||
code: "wrong",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid"),
|
||||
},
|
||||
{
|
||||
name: "code verification ok, get human passwordless error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
|
||||
userAgg, "123", code.Crypted, time.Minute, "", false,
|
||||
),
|
||||
),
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
|
||||
),
|
||||
),
|
||||
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
|
||||
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,
|
||||
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, alg)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
// successful case can't be tested due to random challenge.
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommands_verifyUserPasskeyCode(t *testing.T) {
|
||||
ctx := authz.WithRequestedDomain(context.Background(), "example.com")
|
||||
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
|
||||
es := eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
|
||||
)
|
||||
code, err := newCryptoCodeWithExpiry(ctx, es.Filter, domain.SecretGeneratorTypePasswordlessInitCode, alg)
|
||||
require.NoError(t, err)
|
||||
userAgg := &user.NewAggregate("user1", "org1").Aggregate
|
||||
|
||||
type fields struct {
|
||||
eventstore *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: eventstoreExpect(t,
|
||||
expectFilterError(io.ErrClosedPipe),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
codeID: "123",
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "code verification error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
|
||||
userAgg, "123", code.Crypted, time.Minute, "", false,
|
||||
),
|
||||
),
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
|
||||
),
|
||||
),
|
||||
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
|
||||
expectPush([]*repository.Event{eventFromEventPusher(
|
||||
user.NewHumanPasswordlessInitCodeCheckFailedEvent(ctx, userAgg, "123"),
|
||||
)}),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
codeID: "123",
|
||||
code: "wrong",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(err, "COMMAND-Eeb2a", "Errors.User.Code.Invalid"),
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanPasswordlessInitCodeRequestedEvent(context.Background(),
|
||||
userAgg, "123", code.Crypted, time.Minute, "", false,
|
||||
),
|
||||
),
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanPasswordlessInitCodeSentEvent(ctx, userAgg, "123"),
|
||||
),
|
||||
),
|
||||
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
|
||||
),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
codeID: "123",
|
||||
code: code.Plain,
|
||||
},
|
||||
want: user.NewHumanPasswordlessInitCodeCheckSucceededEvent(ctx, userAgg, "123"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &Commands{
|
||||
eventstore: tt.fields.eventstore,
|
||||
}
|
||||
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 := authz.WithRequestedDomain(context.Background(), "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@test.ch",
|
||||
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"),
|
||||
)),
|
||||
}
|
||||
|
||||
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, []*repository.Event{eventFromEventPusher(
|
||||
user.NewHumanPasswordlessAddedEvent(ctx,
|
||||
userAgg, "123", challenge,
|
||||
),
|
||||
)})
|
||||
},
|
||||
args: args{},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
expectPush: func(challenge string) expect {
|
||||
return expectPush([]*repository.Event{eventFromEventPusher(
|
||||
user.NewHumanPasswordlessAddedEvent(ctx,
|
||||
userAgg, "123", challenge,
|
||||
),
|
||||
)})
|
||||
},
|
||||
args: args{},
|
||||
},
|
||||
{
|
||||
name: "initcode succeeded event",
|
||||
expectPush: func(challenge string) expect {
|
||||
return expectPush([]*repository.Event{
|
||||
eventFromEventPusher(
|
||||
user.NewHumanPasswordlessAddedEvent(ctx,
|
||||
userAgg, "123", challenge,
|
||||
),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
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: eventstoreExpect(t, prep...),
|
||||
webauthnConfig: webauthnConfig,
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
|
||||
}
|
||||
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, "user1", "org1", domain.AuthenticatorAttachmentCrossPlattform)
|
||||
require.NoError(t, err)
|
||||
|
||||
c.eventstore = eventstoreExpect(t, tt.expectPush(webAuthN.Challenge))
|
||||
|
||||
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.PasskeyID)
|
||||
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 cryptoCodeFunc
|
||||
eventstore *eventstore.Eventstore
|
||||
idGenerator id.Generator
|
||||
}
|
||||
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: newCryptoCodeWithExpiry,
|
||||
eventstore: eventstoreExpect(t),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
newCode: mockCode("passkey1", time.Minute),
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
userAgg,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
)),
|
||||
expectPush([]*repository.Event{
|
||||
eventFromEventPusher(
|
||||
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"),
|
||||
},
|
||||
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{
|
||||
newCode: tt.fields.newCode,
|
||||
eventstore: tt.fields.eventstore,
|
||||
idGenerator: tt.fields.idGenerator,
|
||||
}
|
||||
got, err := c.AddUserPasskeyCode(context.Background(), tt.args.userID, tt.args.resourceOwner, alg)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(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 cryptoCodeFunc
|
||||
eventstore *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: newCryptoCodeWithExpiry,
|
||||
eventstore: eventstoreExpect(t),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
urlTmpl: "{{",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "id generator error",
|
||||
fields: fields{
|
||||
newCode: newCryptoCodeWithExpiry,
|
||||
eventstore: eventstoreExpect(t),
|
||||
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: mockCode("passkey1", time.Minute),
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
userAgg,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
)),
|
||||
expectPush([]*repository.Event{
|
||||
eventFromEventPusher(
|
||||
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{
|
||||
newCode: tt.fields.newCode,
|
||||
eventstore: tt.fields.eventstore,
|
||||
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)
|
||||
assert.Equal(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 cryptoCodeFunc
|
||||
eventstore *eventstore.Eventstore
|
||||
idGenerator id.Generator
|
||||
}
|
||||
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: newCryptoCodeWithExpiry,
|
||||
eventstore: eventstoreExpect(t),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
newCode: mockCode("passkey1", time.Minute),
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
userAgg,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
)),
|
||||
expectPush([]*repository.Event{
|
||||
eventFromEventPusher(
|
||||
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"),
|
||||
},
|
||||
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{
|
||||
newCode: tt.fields.newCode,
|
||||
eventstore: tt.fields.eventstore,
|
||||
idGenerator: tt.fields.idGenerator,
|
||||
}
|
||||
got, err := c.AddUserPasskeyCodeReturn(context.Background(), tt.args.userID, tt.args.resourceOwner, alg)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommands_addUserPasskeyCode(t *testing.T) {
|
||||
alg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
|
||||
userAgg := &user.NewAggregate("user1", "org1").Aggregate
|
||||
type fields struct {
|
||||
newCode cryptoCodeFunc
|
||||
eventstore *eventstore.Eventstore
|
||||
idGenerator id.Generator
|
||||
}
|
||||
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: newCryptoCodeWithExpiry,
|
||||
eventstore: eventstoreExpect(t),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectError(t, io.ErrClosedPipe),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "crypto error",
|
||||
fields: fields{
|
||||
newCode: newCryptoCodeWithExpiry,
|
||||
eventstore: eventstoreExpect(t, expectFilterError(io.ErrClosedPipe)),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "filter query error",
|
||||
fields: fields{
|
||||
newCode: newCryptoCodeWithExpiry,
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusher(testSecretGeneratorAddedEvent(domain.SecretGeneratorTypePasswordlessInitCode))),
|
||||
expectFilterError(io.ErrClosedPipe),
|
||||
),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "123"),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "push error",
|
||||
fields: fields{
|
||||
newCode: mockCode("passkey1", time.Minute),
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
userAgg,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
)),
|
||||
expectPushFailed(io.ErrClosedPipe, []*repository.Event{
|
||||
eventFromEventPusher(
|
||||
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"),
|
||||
},
|
||||
args: args{
|
||||
userID: "user1",
|
||||
resourceOwner: "org1",
|
||||
},
|
||||
wantErr: io.ErrClosedPipe,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
fields: fields{
|
||||
newCode: mockCode("passkey1", time.Minute),
|
||||
eventstore: eventstoreExpect(t,
|
||||
expectFilter(eventFromEventPusher(
|
||||
user.NewHumanAddedEvent(context.Background(),
|
||||
userAgg,
|
||||
"username",
|
||||
"firstname",
|
||||
"lastname",
|
||||
"nickname",
|
||||
"displayname",
|
||||
language.German,
|
||||
domain.GenderUnspecified,
|
||||
"email@test.ch",
|
||||
true,
|
||||
),
|
||||
)),
|
||||
expectPush([]*repository.Event{
|
||||
eventFromEventPusher(
|
||||
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"),
|
||||
},
|
||||
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{
|
||||
newCode: tt.fields.newCode,
|
||||
eventstore: tt.fields.eventstore,
|
||||
idGenerator: tt.fields.idGenerator,
|
||||
}
|
||||
got, err := c.addUserPasskeyCode(context.Background(), tt.args.userID, tt.args.resourceOwner, alg, "", false)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
@ -4,12 +4,10 @@ import (
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/errors"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models"
|
||||
)
|
||||
|
||||
@ -73,19 +71,8 @@ type ConfirmURLData struct {
|
||||
OrgID string
|
||||
}
|
||||
|
||||
// RenderConfirmURLTemplate parses and renders tmplStr.
|
||||
// RenderConfirmURLTemplate parses and renders tmpl.
|
||||
// userID, code and orgID are passed into the [ConfirmURLData].
|
||||
// "%s%s?userID=%s&code=%s&orgID=%s"
|
||||
func RenderConfirmURLTemplate(w io.Writer, tmplStr, userID, code, orgID string) error {
|
||||
tmpl, err := template.New("").Parse(tmplStr)
|
||||
if err != nil {
|
||||
return caos_errs.ThrowInvalidArgument(err, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate")
|
||||
}
|
||||
|
||||
data := &ConfirmURLData{userID, code, orgID}
|
||||
if err = tmpl.Execute(w, data); err != nil {
|
||||
return caos_errs.ThrowInvalidArgument(err, "USERv2-ohSi5", "Errors.User.Email.InvalidURLTemplate")
|
||||
}
|
||||
|
||||
return nil
|
||||
func RenderConfirmURLTemplate(w io.Writer, tmpl, userID, code, orgID string) error {
|
||||
return renderURLTemplate(w, tmpl, &ConfirmURLData{userID, code, orgID})
|
||||
}
|
||||
|
@ -81,10 +81,10 @@ func TestEmailValid(t *testing.T) {
|
||||
|
||||
func TestRenderConfirmURLTemplate(t *testing.T) {
|
||||
type args struct {
|
||||
tmplStr string
|
||||
userID string
|
||||
code string
|
||||
orgID string
|
||||
tmpl string
|
||||
userID string
|
||||
code string
|
||||
orgID string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
@ -95,30 +95,30 @@ func TestRenderConfirmURLTemplate(t *testing.T) {
|
||||
{
|
||||
name: "invalid template",
|
||||
args: args{
|
||||
tmplStr: "{{",
|
||||
userID: "user1",
|
||||
code: "123",
|
||||
orgID: "org1",
|
||||
tmpl: "{{",
|
||||
userID: "user1",
|
||||
code: "123",
|
||||
orgID: "org1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "execution error",
|
||||
args: args{
|
||||
tmplStr: "{{.Foo}}",
|
||||
userID: "user1",
|
||||
code: "123",
|
||||
orgID: "org1",
|
||||
tmpl: "{{.Foo}}",
|
||||
userID: "user1",
|
||||
code: "123",
|
||||
orgID: "org1",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ohSi5", "Errors.User.Email.InvalidURLTemplate"),
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-ieYa7", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
tmplStr: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
|
||||
userID: "user1",
|
||||
code: "123",
|
||||
orgID: "org1",
|
||||
tmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
|
||||
userID: "user1",
|
||||
code: "123",
|
||||
orgID: "org1",
|
||||
},
|
||||
want: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
|
||||
},
|
||||
@ -126,7 +126,7 @@ func TestRenderConfirmURLTemplate(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var w strings.Builder
|
||||
err := RenderConfirmURLTemplate(&w, tt.args.tmplStr, tt.args.userID, tt.args.code, tt.args.orgID)
|
||||
err := RenderConfirmURLTemplate(&w, tt.args.tmpl, tt.args.userID, tt.args.code, tt.args.orgID)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.want, w.String())
|
||||
})
|
||||
|
19
internal/domain/url_template.go
Normal file
19
internal/domain/url_template.go
Normal file
@ -0,0 +1,19 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"io"
|
||||
"text/template"
|
||||
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
func renderURLTemplate(w io.Writer, tmpl string, data any) error {
|
||||
parsed, err := template.New("").Parse(tmpl)
|
||||
if err != nil {
|
||||
return caos_errs.ThrowInvalidArgument(err, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate")
|
||||
}
|
||||
if err = parsed.Execute(w, data); err != nil {
|
||||
return caos_errs.ThrowInvalidArgument(err, "DOMAIN-ieYa7", "Errors.User.InvalidURLTemplate")
|
||||
}
|
||||
return nil
|
||||
}
|
56
internal/domain/url_template_test.go
Normal file
56
internal/domain/url_template_test.go
Normal file
@ -0,0 +1,56 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
func Test_renderURLTemplate(t *testing.T) {
|
||||
type args struct {
|
||||
tmpl string
|
||||
data any
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantW string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "parse error",
|
||||
args: args{
|
||||
tmpl: "{{",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "execution error",
|
||||
args: args{
|
||||
tmpl: "{{.Some}}",
|
||||
data: struct{ Foo int }{Foo: 1},
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-ieYa7", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
tmpl: "{{.Foo}}",
|
||||
data: struct{ Foo int }{Foo: 1},
|
||||
},
|
||||
wantW: "1",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := &bytes.Buffer{}
|
||||
err := renderURLTemplate(w, tt.args.tmpl, tt.args.data)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.wantW, w.String())
|
||||
})
|
||||
}
|
||||
}
|
29
internal/domain/user_v2_passkey.go
Normal file
29
internal/domain/user_v2_passkey.go
Normal file
@ -0,0 +1,29 @@
|
||||
package domain
|
||||
|
||||
import "io"
|
||||
|
||||
type PasskeyURLData struct {
|
||||
UserID string
|
||||
OrgID string
|
||||
CodeID string
|
||||
Code string
|
||||
}
|
||||
|
||||
// RenderPasskeyURLTemplate parses and renders tmpl.
|
||||
// userID, orgID, codeID and code are passed into the [PasskeyURLData].
|
||||
func RenderPasskeyURLTemplate(w io.Writer, tmpl, userID, orgID, codeID, code string) error {
|
||||
return renderURLTemplate(w, tmpl, &PasskeyURLData{userID, orgID, codeID, code})
|
||||
}
|
||||
|
||||
type PasskeyCodeDetails struct {
|
||||
*ObjectDetails
|
||||
CodeID string
|
||||
Code string
|
||||
}
|
||||
|
||||
type PasskeyRegistrationDetails struct {
|
||||
*ObjectDetails
|
||||
|
||||
PasskeyID string
|
||||
PublicKeyCredentialCreationOptions []byte
|
||||
}
|
54
internal/domain/user_v2_passkey_test.go
Normal file
54
internal/domain/user_v2_passkey_test.go
Normal file
@ -0,0 +1,54 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
func TestRenderPasskeyURLTemplate(t *testing.T) {
|
||||
type args struct {
|
||||
tmpl string
|
||||
userID string
|
||||
orgID string
|
||||
codeID string
|
||||
code string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantW string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "parse error",
|
||||
args: args{
|
||||
tmpl: "{{",
|
||||
},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
tmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
|
||||
userID: "user1",
|
||||
orgID: "org1",
|
||||
codeID: "99",
|
||||
code: "123",
|
||||
},
|
||||
wantW: "https://example.com/passkey/register?userID=user1&orgID=org1&codeID=99&code=123",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := &bytes.Buffer{}
|
||||
err := RenderPasskeyURLTemplate(w, tt.args.tmpl, tt.args.userID, tt.args.orgID, tt.args.codeID, tt.args.code)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.wantW, w.String())
|
||||
})
|
||||
}
|
||||
}
|
@ -394,6 +394,9 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (
|
||||
if !ok {
|
||||
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-EDtjd", "reduce.wrong.event.type %s", user.HumanPasswordlessInitCodeAddedType)
|
||||
}
|
||||
if e.CodeReturned {
|
||||
return crdb.NewNoOpStatement(e), nil
|
||||
}
|
||||
ctx := HandlerContext(event.Aggregate())
|
||||
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, map[string]interface{}{"id": e.ID}, user.HumanPasswordlessInitCodeSentType)
|
||||
if err != nil {
|
||||
@ -442,7 +445,7 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (
|
||||
e,
|
||||
u.metricSuccessfulDeliveriesEmail,
|
||||
u.metricFailedDeliveriesEmail,
|
||||
).SendPasswordlessRegistrationLink(notifyUser, origin, code, e.ID)
|
||||
).SendPasswordlessRegistrationLink(notifyUser, origin, code, e.ID, e.URLTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -12,27 +12,6 @@ import (
|
||||
)
|
||||
|
||||
func TestNotify_SendEmailVerificationCode(t *testing.T) {
|
||||
type res struct {
|
||||
url string
|
||||
args map[string]interface{}
|
||||
messageType string
|
||||
allowUnverifiedNotificationChannel bool
|
||||
}
|
||||
notify := func(dst *res) Notify {
|
||||
return func(
|
||||
url string,
|
||||
args map[string]interface{},
|
||||
messageType string,
|
||||
allowUnverifiedNotificationChannel bool,
|
||||
) error {
|
||||
dst.url = url
|
||||
dst.args = args
|
||||
dst.messageType = messageType
|
||||
dst.allowUnverifiedNotificationChannel = allowUnverifiedNotificationChannel
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type args struct {
|
||||
user *query.NotifyUser
|
||||
origin string
|
||||
@ -42,7 +21,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *res
|
||||
want *notifyResult
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
@ -56,7 +35,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
|
||||
code: "123",
|
||||
urlTmpl: "",
|
||||
},
|
||||
want: &res{
|
||||
want: ¬ifyResult{
|
||||
url: "https://example.com/ui/login/mail/verification?userID=user1&code=123&orgID=org1",
|
||||
args: map[string]interface{}{"Code": "123"},
|
||||
messageType: domain.VerifyEmailMessageType,
|
||||
@ -74,8 +53,8 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
|
||||
code: "123",
|
||||
urlTmpl: "{{",
|
||||
},
|
||||
want: &res{},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"),
|
||||
want: ¬ifyResult{},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "template success",
|
||||
@ -88,7 +67,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
|
||||
code: "123",
|
||||
urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
|
||||
},
|
||||
want: &res{
|
||||
want: ¬ifyResult{
|
||||
url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
|
||||
args: map[string]interface{}{"Code": "123"},
|
||||
messageType: domain.VerifyEmailMessageType,
|
||||
@ -98,8 +77,8 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := new(res)
|
||||
err := notify(got).SendEmailVerificationCode(tt.args.user, tt.args.origin, tt.args.code, tt.args.urlTmpl)
|
||||
got, notify := mockNotify()
|
||||
err := notify.SendEmailVerificationCode(tt.args.user, tt.args.origin, tt.args.code, tt.args.urlTmpl)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
|
@ -1,12 +1,24 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/ui/login"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, origin, code, codeID string) error {
|
||||
url := domain.PasswordlessInitCodeLink(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code)
|
||||
func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, origin, code, codeID, urlTmpl string) error {
|
||||
var url string
|
||||
if urlTmpl == "" {
|
||||
url = domain.PasswordlessInitCodeLink(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code)
|
||||
} else {
|
||||
var buf strings.Builder
|
||||
if err := domain.RenderPasskeyURLTemplate(&buf, urlTmpl, user.ID, user.ResourceOwner, codeID, code); err != nil {
|
||||
return err
|
||||
}
|
||||
url = buf.String()
|
||||
}
|
||||
|
||||
return notify(url, nil, domain.PasswordlessRegistrationMessageType, true)
|
||||
}
|
||||
|
@ -0,0 +1,88 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
"github.com/zitadel/zitadel/internal/query"
|
||||
)
|
||||
|
||||
func TestNotify_SendPasswordlessRegistrationLink(t *testing.T) {
|
||||
type args struct {
|
||||
user *query.NotifyUser
|
||||
origin string
|
||||
code string
|
||||
codeID string
|
||||
urlTmpl string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *notifyResult
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "default URL",
|
||||
args: args{
|
||||
user: &query.NotifyUser{
|
||||
ID: "user1",
|
||||
ResourceOwner: "org1",
|
||||
},
|
||||
origin: "https://example.com",
|
||||
code: "123",
|
||||
codeID: "456",
|
||||
urlTmpl: "",
|
||||
},
|
||||
want: ¬ifyResult{
|
||||
url: "https://example.com/ui/login/login/passwordless/init?userID=user1&orgID=org1&codeID=456&code=123",
|
||||
messageType: domain.PasswordlessRegistrationMessageType,
|
||||
allowUnverifiedNotificationChannel: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template error",
|
||||
args: args{
|
||||
user: &query.NotifyUser{
|
||||
ID: "user1",
|
||||
ResourceOwner: "org1",
|
||||
},
|
||||
origin: "https://example.com",
|
||||
code: "123",
|
||||
codeID: "456",
|
||||
urlTmpl: "{{",
|
||||
},
|
||||
want: ¬ifyResult{},
|
||||
wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
|
||||
},
|
||||
{
|
||||
name: "template success",
|
||||
args: args{
|
||||
user: &query.NotifyUser{
|
||||
ID: "user1",
|
||||
ResourceOwner: "org1",
|
||||
},
|
||||
origin: "https://example.com",
|
||||
code: "123",
|
||||
codeID: "456",
|
||||
urlTmpl: "https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}",
|
||||
},
|
||||
want: ¬ifyResult{
|
||||
url: "https://example.com/passkey/register?userID=user1&orgID=org1&codeID=456&code=123",
|
||||
messageType: domain.PasswordlessRegistrationMessageType,
|
||||
allowUnverifiedNotificationChannel: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, notify := mockNotify()
|
||||
err := notify.SendPasswordlessRegistrationLink(tt.args.user, tt.args.origin, tt.args.code, tt.args.codeID, tt.args.urlTmpl)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
23
internal/notification/types/types_test.go
Normal file
23
internal/notification/types/types_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package types
|
||||
|
||||
type notifyResult struct {
|
||||
url string
|
||||
args map[string]interface{}
|
||||
messageType string
|
||||
allowUnverifiedNotificationChannel bool
|
||||
}
|
||||
|
||||
// mockNotify returns a notifyResult and Notify function for easy mocking.
|
||||
// The notifyResult will only be populated after Notify is called.
|
||||
func mockNotify() (*notifyResult, Notify) {
|
||||
dst := new(notifyResult)
|
||||
return dst, func(url string, args map[string]interface{}, messageType string, allowUnverifiedNotificationChannel bool) error {
|
||||
*dst = notifyResult{
|
||||
url: url,
|
||||
args: args,
|
||||
messageType: messageType,
|
||||
allowUnverifiedNotificationChannel: allowUnverifiedNotificationChannel,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
@ -315,9 +315,11 @@ func HumanPasswordlessInitCodeAddedEventMapper(event *repository.Event) (eventst
|
||||
type HumanPasswordlessInitCodeRequestedEvent struct {
|
||||
eventstore.BaseEvent `json:"-"`
|
||||
|
||||
ID string `json:"id"`
|
||||
Code *crypto.CryptoValue `json:"code"`
|
||||
Expiry time.Duration `json:"expiry"`
|
||||
ID string `json:"id"`
|
||||
Code *crypto.CryptoValue `json:"code"`
|
||||
Expiry time.Duration `json:"expiry"`
|
||||
URLTemplate string `json:"url_template,omitempty"`
|
||||
CodeReturned bool `json:"code_returned,omitempty"`
|
||||
}
|
||||
|
||||
func (e *HumanPasswordlessInitCodeRequestedEvent) Data() interface{} {
|
||||
@ -334,6 +336,8 @@ func NewHumanPasswordlessInitCodeRequestedEvent(
|
||||
id string,
|
||||
code *crypto.CryptoValue,
|
||||
expiry time.Duration,
|
||||
urlTmpl string,
|
||||
codeReturned bool,
|
||||
) *HumanPasswordlessInitCodeRequestedEvent {
|
||||
return &HumanPasswordlessInitCodeRequestedEvent{
|
||||
BaseEvent: *eventstore.NewBaseEventForPush(
|
||||
@ -341,9 +345,11 @@ func NewHumanPasswordlessInitCodeRequestedEvent(
|
||||
aggregate,
|
||||
HumanPasswordlessInitCodeRequestedType,
|
||||
),
|
||||
ID: id,
|
||||
Code: code,
|
||||
Expiry: expiry,
|
||||
ID: id,
|
||||
Code: code,
|
||||
Expiry: expiry,
|
||||
URLTemplate: urlTmpl,
|
||||
CodeReturned: codeReturned,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -53,6 +53,7 @@ Errors:
|
||||
NotFoundOnOrg: Benutzer konnte in der gewünschten Organisation nicht gefunden werden
|
||||
NotAllowedOrg: Benutzer gehört nicht der benötigten Organisation an
|
||||
UserIDMissing: User ID fehlt
|
||||
UserIDWrong: "Der Anforderungsbenutzer ist nicht gleich dem authentifizierten Benutzer"
|
||||
DomainPolicyNil: Organisation Policy ist leer
|
||||
EmailAsUsernameNotAllowed: Benutzername darf keine E-Mail Adresse sein
|
||||
Invalid: Benutzerdaten sind ungültig
|
||||
@ -67,6 +68,7 @@ Errors:
|
||||
NoChanges: Keine Änderungen gefunden
|
||||
InitCodeNotFound: Kein Initialisierungs-Code gefunden
|
||||
UsernameNotChanged: Benutzername wurde nicht verändert
|
||||
InvalidURLTemplate: URL Template ist ungültig
|
||||
Profile:
|
||||
NotFound: Profil nicht gefunden
|
||||
NotChanged: Profil nicht verändert
|
||||
@ -81,7 +83,6 @@ Errors:
|
||||
NotChanged: Email wurde nicht geändert
|
||||
Empty: Email ist leer
|
||||
IDMissing: Email ID fehlt
|
||||
InvalidURLTemplate: URL Template ist ungültig
|
||||
Phone:
|
||||
NotFound: Telefonnummer nicht gefunden
|
||||
Invalid: Telefonnummer ist ungültig
|
||||
|
@ -53,6 +53,7 @@ Errors:
|
||||
NotFoundOnOrg: User could not be found on chosen organization
|
||||
NotAllowedOrg: User is no member of the required organization
|
||||
UserIDMissing: User ID missing
|
||||
UserIDWrong: "Request user not equal to authenticated user"
|
||||
DomainPolicyNil: Organisation Policy is empty
|
||||
EmailAsUsernameNotAllowed: Email is not allowed as username
|
||||
Invalid: Userdata is invalid
|
||||
@ -67,6 +68,7 @@ Errors:
|
||||
NoChanges: No changes found
|
||||
InitCodeNotFound: Initialization Code not found
|
||||
UsernameNotChanged: Username not changed
|
||||
InvalidURLTemplate: URL Template is invalid
|
||||
Profile:
|
||||
NotFound: Profile not found
|
||||
NotChanged: Profile not changed
|
||||
@ -81,7 +83,6 @@ Errors:
|
||||
NotChanged: Email not changed
|
||||
Empty: Email is empty
|
||||
IDMissing: Email ID is missing
|
||||
InvalidURLTemplate: URL Template is invalid
|
||||
Phone:
|
||||
NotFound: Phone not found
|
||||
Invalid: Phone is invalid
|
||||
|
@ -53,6 +53,7 @@ Errors:
|
||||
NotFoundOnOrg: El usuario no pudo encontrarse en la organización elegida
|
||||
NotAllowedOrg: El usuario no es miembro de la organización requerida
|
||||
UserIDMissing: Falta el ID de usuario
|
||||
UserIDWrong: "Solicitud de usuario no igual al usuario autenticado"
|
||||
DomainPolicyNil: Falta la política de la organización
|
||||
EmailAsUsernameNotAllowed: La dirección de Email no se permite como nombre de usuario
|
||||
Invalid: Los datos de usuario no son válidos
|
||||
@ -67,6 +68,7 @@ Errors:
|
||||
NoChanges: No se encontraron cambios
|
||||
InitCodeNotFound: Código de inicialización no encontrado
|
||||
UsernameNotChanged: El nombre de usuario no cambió
|
||||
InvalidURLTemplate: La plantilla URL no es válida
|
||||
Profile:
|
||||
NotFound: Perfil no encontrado
|
||||
NotChanged: El perfil no ha cambiado
|
||||
@ -81,7 +83,6 @@ Errors:
|
||||
NotChanged: El email no ha cambiado
|
||||
Empty: El email no está vacío
|
||||
IDMissing: Falta el ID del email
|
||||
InvalidURLTemplate: La plantilla URL no es válida
|
||||
Phone:
|
||||
NotFound: Teléfono no encontrado
|
||||
Invalid: El teléfono no es válido
|
||||
|
@ -53,6 +53,7 @@ Errors:
|
||||
NotFoundOnOrg: L'utilisateur n'a pas été trouvé dans l'organisation choisie
|
||||
NotAllowedOrg: L'utilisateur n'est pas membre de l'organisation requise
|
||||
UserIDMissing: L'ID de l'utilisateur est manquant
|
||||
UserIDWrong: "L'utilisateur de la demande n'est pas égal à l'utilisateur authentifié"
|
||||
DomainPolicyNil: La politique de l'organisation est vide
|
||||
EmailAsUsernameNotAllowed: L'email n'est pas autorisé comme nom d'utilisateur
|
||||
Invalid: Les données de l'utilisateur ne sont pas valides
|
||||
@ -67,6 +68,7 @@ Errors:
|
||||
NoChanges: Aucun changement trouvé
|
||||
InitCodeNotFound: Code d'initialisation non trouvé
|
||||
UsernameNotChanged: Nom d'utilisateur non modifié
|
||||
InvalidURLTemplate: Le modèle d'URL n'est pas valide
|
||||
Profile:
|
||||
NotFound: Profil non trouvé
|
||||
NotChanged: Le profil n'a pas changé
|
||||
@ -81,7 +83,6 @@ Errors:
|
||||
NotChanged: L'adresse électronique n'a pas changé
|
||||
Empty: Email est vide
|
||||
IDMissing: Email ID manquant
|
||||
InvalidURLTemplate: Le modèle d'URL n'est pas valide
|
||||
Phone:
|
||||
Notfound: Téléphone non trouvé
|
||||
Invalid: Le téléphone n'est pas valide
|
||||
|
@ -53,6 +53,7 @@ Errors:
|
||||
NotFoundOnOrg: L'utente non è stato trovato nell'organizzazione scelta
|
||||
NotAllowedOrg: L'utente non è membro dell'organizzazione richiesta
|
||||
UserIDMissing: ID utente mancante
|
||||
UserIDWrong: "Utente richiesta non uguale all'utente autenticato"
|
||||
DomainPolicyNil: Impostazione Org IAM mancante
|
||||
EmailAsUsernameNotAllowed: L'e-mail non è consentita come nome utente
|
||||
Invalid: I dati utente non sono validi
|
||||
@ -67,6 +68,7 @@ Errors:
|
||||
NoChanges: Nessun cambiamento trovato
|
||||
InitCodeNotFound: Codice di inizializzazione non trovato
|
||||
UsernameNotChanged: Nome utente non cambiato
|
||||
InvalidURLTemplate: Il modello di URL non è valido
|
||||
Profile:
|
||||
NotFound: Profilo non trovato
|
||||
NotChanged: Profilo non cambiato
|
||||
@ -81,7 +83,6 @@ Errors:
|
||||
NotChanged: Email non cambiata
|
||||
Empty: Email è vuota
|
||||
IDMissing: Email ID mancante
|
||||
InvalidURLTemplate: Il modello di URL non è valido
|
||||
Phone:
|
||||
NotFound: Telefono non trovato
|
||||
Invalid: Il telefono non è valido
|
||||
|
@ -53,6 +53,7 @@ Errors:
|
||||
NotFoundOnOrg: ユーザーが選択した組織内で見つかりません
|
||||
NotAllowedOrg: ユーザーが必要な組織のメンバーでありません
|
||||
UserIDMissing: ユーザーIDがありません
|
||||
UserIDWrong: "リクエストユーザーが認証されたユーザーと等しくない"
|
||||
DomainPolicyNil: 組織ポリシーが空です
|
||||
EmailAsUsernameNotAllowed: メールアドレスはユーザー名として使用できません
|
||||
Invalid: 無効なユーザーデータです
|
||||
@ -67,6 +68,7 @@ Errors:
|
||||
NoChanges: 変更は見つかりません
|
||||
InitCodeNotFound: 初期化コードが見つかりません
|
||||
UsernameNotChanged: ユーザー名は変更されていません
|
||||
InvalidURLTemplate: URLテンプレートが無効です
|
||||
Profile:
|
||||
NotFound: プロファイルが見つかりません
|
||||
NotChanged: プロファイルが変更されていません
|
||||
@ -76,7 +78,6 @@ Errors:
|
||||
Invalid: 無効なメールアドレスです
|
||||
AlreadyVerified: メールアドレスはすでに検証済みです
|
||||
NotChanged: メールアドレスが変更されていません
|
||||
InvalidURLTemplate: URLテンプレートが無効です
|
||||
Phone:
|
||||
NotFound: 電話番号が見つかりません
|
||||
Invalid: 無効な電話番号です
|
||||
|
@ -53,6 +53,7 @@ Errors:
|
||||
NotFoundOnOrg: Użytkownik nie został znaleziony w wybranej organizacji
|
||||
NotAllowedOrg: Użytkownik nie jest członkiem wymaganej organizacji
|
||||
UserIDMissing: Brakuje ID użytkownika
|
||||
UserIDWrong: "Żądanie użytkownika nie jest równe uwierzytelnionemu użytkownikowi"
|
||||
DomainPolicyNil: Polityka organizacji jest pusta
|
||||
EmailAsUsernameNotAllowed: Adres e-mail nie jest dozwolony jako nazwa użytkownika
|
||||
Invalid: Dane użytkownika są nieprawidłowe
|
||||
@ -67,6 +68,7 @@ Errors:
|
||||
NoChanges: Nie znaleziono zmian
|
||||
InitCodeNotFound: Kod inicjalizacji nie znaleziony
|
||||
UsernameNotChanged: Nazwa użytkownika nie została zmieniona
|
||||
InvalidURLTemplate: Szablon URL jest nieprawidłowy
|
||||
Profile:
|
||||
NotFound: Profil nie znaleziony
|
||||
NotChanged: Profil nie zmieniony
|
||||
@ -81,7 +83,6 @@ Errors:
|
||||
NotChanged: Adres e-mail nie zmieniony
|
||||
Empty: Adres e-mail jest pusty
|
||||
IDMissing: Adres e-mail ID brakuje
|
||||
InvalidURLTemplate: Szablon URL jest nieprawidłowy
|
||||
Phone:
|
||||
NotFound: Numer telefonu nie znaleziony
|
||||
Invalid: Numer telefonu jest nieprawidłowy
|
||||
|
@ -53,6 +53,7 @@ Errors:
|
||||
NotFoundOnOrg: 在所选组织中找不到用户
|
||||
NotAllowedOrg: 用户不是所需组织的成员
|
||||
UserIDMissing: 缺少用户 ID
|
||||
UserIDWrong: "请求用户不等于经过身份验证的用户"
|
||||
DomainPolicyNil: 组织策略为空
|
||||
EmailAsUsernameNotAllowed: 电子邮件不允许作为用户名
|
||||
Invalid: 用户数据无效
|
||||
@ -67,6 +68,7 @@ Errors:
|
||||
NoChanges: 未发现任何更改
|
||||
InitCodeNotFound: 未找到初始化验证码
|
||||
UsernameNotChanged: 用户名未更改
|
||||
InvalidURLTemplate: URL模板无效
|
||||
Profile:
|
||||
NotFound: 未找到个人资料
|
||||
NotChanged: 个人资料未更改
|
||||
@ -81,7 +83,6 @@ Errors:
|
||||
NotChanged: 电子邮件未更改
|
||||
Empty: 电子邮件是空的
|
||||
IDMissing: 电子邮件ID丢失
|
||||
InvalidURLTemplate: URL模板无效
|
||||
Phone:
|
||||
NotFound: 手机号码未找到
|
||||
Invalid: 手机号码无效
|
||||
|
36
internal/webauthn/client.go
Normal file
36
internal/webauthn/client.go
Normal file
@ -0,0 +1,36 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/descope/virtualwebauthn"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
rp virtualwebauthn.RelyingParty
|
||||
auth virtualwebauthn.Authenticator
|
||||
credential virtualwebauthn.Credential
|
||||
}
|
||||
|
||||
func NewClient(name, domain, origin string) *Client {
|
||||
rp := virtualwebauthn.RelyingParty{
|
||||
Name: name,
|
||||
ID: domain,
|
||||
Origin: origin,
|
||||
}
|
||||
return &Client{
|
||||
rp: rp,
|
||||
auth: virtualwebauthn.NewAuthenticator(),
|
||||
credential: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) CreateAttestationResponse(options []byte) ([]byte, error) {
|
||||
parsedAttestationOptions, err := virtualwebauthn.ParseAttestationOptions(string(options))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("webauthn.Client.CreateAttestationResponse: %w", err)
|
||||
}
|
||||
return []byte(virtualwebauthn.CreateAttestationResponse(
|
||||
c.rp, c.auth, c.credential, *parsedAttestationOptions,
|
||||
)), nil
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"github.com/duo-labs/webauthn/protocol"
|
||||
"github.com/duo-labs/webauthn/webauthn"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
)
|
||||
|
@ -5,8 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/duo-labs/webauthn/protocol"
|
||||
"github.com/duo-labs/webauthn/webauthn"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/zitadel/logging"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
@ -177,9 +177,13 @@ func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN *
|
||||
|
||||
func (w *Config) serverFromContext(ctx context.Context) (*webauthn.WebAuthn, error) {
|
||||
instance := authz.GetInstance(ctx)
|
||||
return webauthn.New(&webauthn.Config{
|
||||
webAuthn, err := webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: w.DisplayName,
|
||||
RPID: instance.RequestedDomain(),
|
||||
RPOrigin: http.BuildOrigin(instance.RequestedHost(), w.ExternalSecure),
|
||||
RPOrigins: []string{http.BuildOrigin(instance.RequestedHost(), w.ExternalSecure)},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, caos_errs.ThrowInternal(err, "WEBAU-UX9ta", "Errors.User.WebAuthN.ServerConfig")
|
||||
}
|
||||
return webAuthn, nil
|
||||
}
|
||||
|
58
internal/webauthn/webauthn_test.go
Normal file
58
internal/webauthn/webauthn_test.go
Normal file
@ -0,0 +1,58 @@
|
||||
package webauthn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/api/authz"
|
||||
caos_errs "github.com/zitadel/zitadel/internal/errors"
|
||||
)
|
||||
|
||||
func TestConfig_serverFromContext(t *testing.T) {
|
||||
type args struct {
|
||||
ctx context.Context
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *webauthn.WebAuthn
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "webauthn error",
|
||||
args: args{context.Background()},
|
||||
wantErr: caos_errs.ThrowInternal(nil, "WEBAU-UX9ta", "Errors.User.WebAuthN.ServerConfig"),
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
args: args{authz.WithRequestedDomain(context.Background(), "example.com")},
|
||||
want: &webauthn.WebAuthn{
|
||||
Config: &webauthn.Config{
|
||||
RPDisplayName: "DisplayName",
|
||||
RPID: "example.com",
|
||||
RPOrigins: []string{"https://example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := &Config{
|
||||
DisplayName: "DisplayName",
|
||||
ExternalSecure: true,
|
||||
}
|
||||
got, err := w.serverFromContext(tt.args.ctx)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
if tt.want != nil {
|
||||
require.NotNil(t, got)
|
||||
assert.Equal(t, tt.want.Config.RPDisplayName, got.Config.RPDisplayName)
|
||||
assert.Equal(t, tt.want.Config.RPID, got.Config.RPID)
|
||||
assert.Equal(t, tt.want.Config.RPOrigins, got.Config.RPOrigins)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@ -20,7 +20,7 @@ message SendPasskeyRegistrationLink {
|
||||
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
|
||||
min_length: 1;
|
||||
max_length: 200;
|
||||
example: "\"https://example.com/passkey/register?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\"";
|
||||
example: "\"https://example.com/passkey/register?userID={{.UserID}}&orgID={{.OrgID}}&codeID={{.CodeID}}&code={{.Code}}\"";
|
||||
description: "\"Optionally set a url_template, which will be used in the mail sent by ZITADEL to guide the user to your passkey registration page. If no template is set, the default ZITADEL url will be used.\""
|
||||
}
|
||||
];
|
||||
|
Loading…
Reference in New Issue
Block a user