Merge branch 'next' into rc

This commit is contained in:
adlerhurst 2023-06-08 09:46:04 +02:00
commit c39193b1ed
43 changed files with 2479 additions and 515 deletions

View File

@ -735,6 +735,7 @@ InternalAuthZ:
- "user.grant.delete" - "user.grant.delete"
- "user.membership.read" - "user.membership.read"
- "user.credential.write" - "user.credential.write"
- "user.passkey.write"
- "policy.read" - "policy.read"
- "policy.write" - "policy.write"
- "policy.delete" - "policy.delete"
@ -811,6 +812,7 @@ InternalAuthZ:
- "user.grant.delete" - "user.grant.delete"
- "user.membership.read" - "user.membership.read"
- "user.credential.write" - "user.credential.write"
- "user.passkey.write"
- "policy.read" - "policy.read"
- "policy.write" - "policy.write"
- "policy.delete" - "policy.delete"
@ -847,6 +849,7 @@ InternalAuthZ:
- "user.grant.write" - "user.grant.write"
- "user.grant.delete" - "user.grant.delete"
- "user.membership.read" - "user.membership.read"
- "user.passkey.write"
- "project.read" - "project.read"
- "project.member.read" - "project.member.read"
- "project.role.read" - "project.role.read"
@ -882,6 +885,7 @@ InternalAuthZ:
- "user.grant.delete" - "user.grant.delete"
- "user.membership.read" - "user.membership.read"
- "user.credential.write" - "user.credential.write"
- "user.passkey.write"
- "policy.read" - "policy.read"
- "policy.write" - "policy.write"
- "policy.delete" - "policy.delete"

12
go.mod
View File

@ -15,12 +15,13 @@ require (
github.com/benbjohnson/clock v1.3.0 github.com/benbjohnson/clock v1.3.0
github.com/boombuler/barcode v1.0.1 github.com/boombuler/barcode v1.0.1
github.com/cockroachdb/cockroach-go/v2 v2.3.3 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 v0.0.0-20230402114112-623f9dda9079
github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124 github.com/dop251/goja_nodejs v0.0.0-20230322100729-2550c7b6c124
github.com/drone/envsubst v1.0.3 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/envoyproxy/protoc-gen-validate v0.10.1
github.com/go-ldap/ldap/v3 v3.4.4 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/glog v1.1.1
github.com/golang/mock v1.6.0 github.com/golang/mock v1.6.0
github.com/golang/protobuf v1.5.3 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 v1.14.0
go.opentelemetry.io/otel/sdk/metric v0.37.0 go.opentelemetry.io/otel/sdk/metric v0.37.0
go.opentelemetry.io/otel/trace v1.14.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/net v0.10.0
golang.org/x/oauth2 v0.8.0 golang.org/x/oauth2 v0.8.0
golang.org/x/sync v0.1.0 golang.org/x/sync v0.1.0
@ -86,12 +87,16 @@ require (
require ( require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.37.0 // indirect 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/logr v1.2.4 // indirect
github.com/go-logr/stdr v1.2.2 // 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/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/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/pelletier/go-toml/v2 v2.0.7 // 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.opentelemetry.io/otel/exporters/otlp/internal/retry v1.14.0 // indirect
go.uber.org/multierr v1.11.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/geo v0.0.0-20230404232722-c4acd7a044dc // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/snappy v0.0.4 // 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/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect

363
go.sum

File diff suppressed because it is too large Load Diff

View 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
}

View File

@ -11,38 +11,13 @@ import (
"google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/durationpb" "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/domain"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha" settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2alpha"
) )
var ignoreMessageTypes = map[protoreflect.FullName]bool{ var ignoreTypes = []protoreflect.FullName{"google.protobuf.Duration"}
"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())
}
}
}
func Test_loginSettingsToPb(t *testing.T) { func Test_loginSettingsToPb(t *testing.T) {
arg := &query.LoginPolicy{ arg := &query.LoginPolicy{
@ -100,7 +75,7 @@ func Test_loginSettingsToPb(t *testing.T) {
} }
got := loginSettingsToPb(arg) got := loginSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect()) grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) { if !proto.Equal(got, want) {
t.Errorf("loginSettingsToPb() =\n%v\nwant\n%v", 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) got := passwordSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect()) grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) { if !proto.Equal(got, want) {
t.Errorf("passwordSettingsToPb() =\n%v\nwant\n%v", 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") got := brandingSettingsToPb(arg, "http://example.com")
allFieldsSet(t, got.ProtoReflect()) grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) { if !proto.Equal(got, want) {
t.Errorf("brandingSettingsToPb() =\n%v\nwant\n%v", 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, ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
} }
got := domainSettingsToPb(arg) got := domainSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect()) grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) { if !proto.Equal(got, want) {
t.Errorf("domainSettingsToPb() =\n%v\nwant\n%v", 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, ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
} }
got := legalAndSupportSettingsToPb(arg) got := legalAndSupportSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect()) grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) { if !proto.Equal(got, want) {
t.Errorf("legalSettingsToPb() =\n%v\nwant\n%v", 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, ResourceOwnerType: settings.ResourceOwnerType_RESOURCE_OWNER_TYPE_INSTANCE,
} }
got := lockoutSettingsToPb(arg) got := lockoutSettingsToPb(arg)
allFieldsSet(t, got.ProtoReflect()) grpc.AllFieldsSet(t, got.ProtoReflect(), ignoreTypes...)
if !proto.Equal(got, want) { if !proto.Equal(got, want) {
t.Errorf("lockoutSettingsToPb() =\n%v\nwant\n%v", 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) got := identityProvidersToPb(arg)
require.Len(t, got, len(got)) require.Len(t, got, len(got))
for i, v := range got { for i, v := range got {
allFieldsSet(t, v.ProtoReflect()) grpc.AllFieldsSet(t, v.ProtoReflect(), ignoreTypes...)
if !proto.Equal(v, want[i]) { if !proto.Equal(v, want[i]) {
t.Errorf("identityProvidersToPb() =\n%v\nwant\n%v", got, want) t.Errorf("identityProvidersToPb() =\n%v\nwant\n%v", got, want)
} }

View 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
}

View 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())
}
})
}
}

View 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: &timestamppb.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: &timestamppb.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: &timestamppb.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())
}
})
}
}

View File

@ -32,7 +32,7 @@ type Commands struct {
httpClient *http.Client httpClient *http.Client
checkPermission domain.PermissionCheck checkPermission domain.PermissionCheck
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, codeAlg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) newCode cryptoCodeFunc
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
static static.Storage static static.Storage
@ -109,7 +109,7 @@ func StartCommands(
webauthnConfig: webAuthN, webauthnConfig: webAuthN,
httpClient: httpClient, httpClient: httpClient,
checkPermission: permissionCheck, checkPermission: permissionCheck,
newEmailCode: newEmailCode, newCode: newCryptoCodeWithExpiry,
sessionTokenCreator: sessionTokenCreator(idGenerator, sessionAlg), sessionTokenCreator: sessionTokenCreator(idGenerator, sessionAlg),
sessionTokenVerifier: sessionTokenVerifier, sessionTokenVerifier: sessionTokenVerifier,
} }

View File

@ -10,6 +10,8 @@ import (
"github.com/zitadel/zitadel/internal/errors" "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 { type CryptoCodeWithExpiry struct {
Crypted *crypto.CryptoValue Crypted *crypto.CryptoValue
Plain string 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) { 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 { if err != nil {
return nil, err return nil, err
} }
crypted, plain, err := crypto.NewCode(gen)
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")
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &CryptoCodeWithExpiry{
Crypted: crypted,
Plain: plain,
Expiry: config.Expiry,
}, nil
}
code.Expiry = config.Expiry 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 {
return code, nil 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) { 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 { if err != nil {
return nil, "", err 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) { switch a := alg.(type) {
case crypto.HashAlgorithm: case crypto.HashAlgorithm:
return crypto.NewCode(crypto.NewHashGenerator(*config, a)) return crypto.NewHashGenerator(*config, a), config, nil
case crypto.EncryptionAlgorithm: 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) { func secretGeneratorConfig(ctx context.Context, filter preparation.FilterToQueryReducer, typ domain.SecretGeneratorType) (*crypto.GeneratorConfig, error) {

View 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)
})
}
}

View File

@ -23,6 +23,6 @@ func (e *Email) Validate() error {
return e.Address.Validate() return e.Address.Validate()
} }
func newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) { func (c *Commands) newEmailCode(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) {
return newCryptoCodeWithExpiry(ctx, filter, domain.SecretGeneratorTypeVerifyEmailCode, alg) return c.newCode(ctx, filter, domain.SecretGeneratorTypeVerifyEmailCode, alg)
} }

View File

@ -31,7 +31,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
idGenerator id.Generator idGenerator id.Generator
userPasswordAlg crypto.HashAlgorithm userPasswordAlg crypto.HashAlgorithm
codeAlg crypto.EncryptionAlgorithm codeAlg crypto.EncryptionAlgorithm
newEmailCode func(ctx context.Context, filter preparation.FilterToQueryReducer, alg crypto.EncryptionAlgorithm) (*CryptoCodeWithExpiry, error) newCode cryptoCodeFunc
} }
type args struct { type args struct {
ctx context.Context ctx context.Context
@ -446,7 +446,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
newEmailCode: mockEmailCode("emailCode", time.Hour), newCode: mockCode("emailCode", time.Hour),
}, },
args: args{ args: args{
ctx: context.Background(), ctx: context.Background(),
@ -526,7 +526,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)), userPasswordAlg: crypto.CreateMockHashAlg(gomock.NewController(t)),
codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), codeAlg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
newEmailCode: mockEmailCode("emailCode", time.Hour), newCode: mockCode("emailCode", time.Hour),
}, },
args: args{ args: args{
ctx: context.Background(), ctx: context.Background(),
@ -1202,7 +1202,7 @@ func TestCommandSide_AddHuman(t *testing.T) {
userPasswordAlg: tt.fields.userPasswordAlg, userPasswordAlg: tt.fields.userPasswordAlg,
userEncryption: tt.fields.codeAlg, userEncryption: tt.fields.codeAlg,
idGenerator: tt.fields.idGenerator, 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) err := r.AddHuman(tt.args.ctx, tt.args.orgID, tt.args.human, tt.args.allowInitMail)
if tt.res.err == nil { 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
}
}

View File

@ -539,7 +539,7 @@ func (c *Commands) humanAddPasswordlessInitCode(ctx context.Context, userID, res
} }
if !direct { if !direct {
codeEventCreator = func(ctx context.Context, agg *eventstore.Aggregate, id string, cryptoCode *crypto.CryptoValue, exp time.Duration) eventstore.Command { 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()) codeEvent := codeEventCreator(ctx, UserAggregateFromWriteModel(&initCode.WriteModel), codeID, cryptoCode, passwordlessCodeGenerator.Expiry())

View File

@ -512,6 +512,9 @@ func (wm *HumanPasswordlessInitCodeWriteModel) appendRequestedEvent(e *user.Huma
wm.CryptoCode = e.Code wm.CryptoCode = e.Code
wm.Expiration = e.Expiry wm.Expiration = e.Expiry
wm.State = domain.PasswordlessInitCodeStateRequested wm.State = domain.PasswordlessInitCodeStateRequested
if e.CodeReturned {
wm.State = domain.PasswordlessInitCodeStateActive
}
} }
func (wm *HumanPasswordlessInitCodeWriteModel) appendCheckFailedEvent(e *user.HumanPasswordlessInitCodeCheckFailedEvent) { func (wm *HumanPasswordlessInitCodeWriteModel) appendCheckFailedEvent(e *user.HumanPasswordlessInitCodeCheckFailedEvent) {

View File

@ -199,7 +199,7 @@ func TestCommands_ChangeUserEmailURLTemplate(t *testing.T) {
email: "email-changed@test.ch", email: "email-changed@test.ch",
urlTmpl: "{{", 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", name: "permission missing",

View 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
}

View 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)
})
}
}

View File

@ -4,12 +4,10 @@ import (
"io" "io"
"regexp" "regexp"
"strings" "strings"
"text/template"
"time" "time"
"github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/errors"
caos_errs "github.com/zitadel/zitadel/internal/errors"
es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models" es_models "github.com/zitadel/zitadel/internal/eventstore/v1/models"
) )
@ -73,19 +71,8 @@ type ConfirmURLData struct {
OrgID string OrgID string
} }
// RenderConfirmURLTemplate parses and renders tmplStr. // RenderConfirmURLTemplate parses and renders tmpl.
// userID, code and orgID are passed into the [ConfirmURLData]. // userID, code and orgID are passed into the [ConfirmURLData].
// "%s%s?userID=%s&code=%s&orgID=%s" func RenderConfirmURLTemplate(w io.Writer, tmpl, userID, code, orgID string) error {
func RenderConfirmURLTemplate(w io.Writer, tmplStr, userID, code, orgID string) error { return renderURLTemplate(w, tmpl, &ConfirmURLData{userID, code, orgID})
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
} }

View File

@ -81,10 +81,10 @@ func TestEmailValid(t *testing.T) {
func TestRenderConfirmURLTemplate(t *testing.T) { func TestRenderConfirmURLTemplate(t *testing.T) {
type args struct { type args struct {
tmplStr string tmpl string
userID string userID string
code string code string
orgID string orgID string
} }
tests := []struct { tests := []struct {
name string name string
@ -95,30 +95,30 @@ func TestRenderConfirmURLTemplate(t *testing.T) {
{ {
name: "invalid template", name: "invalid template",
args: args{ args: args{
tmplStr: "{{", tmpl: "{{",
userID: "user1", userID: "user1",
code: "123", code: "123",
orgID: "org1", 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", name: "execution error",
args: args{ args: args{
tmplStr: "{{.Foo}}", tmpl: "{{.Foo}}",
userID: "user1", userID: "user1",
code: "123", code: "123",
orgID: "org1", 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", name: "success",
args: args{ args: args{
tmplStr: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", tmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
userID: "user1", userID: "user1",
code: "123", code: "123",
orgID: "org1", orgID: "org1",
}, },
want: "https://example.com/email/verify?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 { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
var w strings.Builder 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) require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, w.String()) assert.Equal(t, tt.want, w.String())
}) })

View 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
}

View 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())
})
}
}

View 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
}

View 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())
})
}
}

View File

@ -394,6 +394,9 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (
if !ok { if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-EDtjd", "reduce.wrong.event.type %s", user.HumanPasswordlessInitCodeAddedType) 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()) ctx := HandlerContext(event.Aggregate())
alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, map[string]interface{}{"id": e.ID}, user.HumanPasswordlessInitCodeSentType) alreadyHandled, err := u.checkIfCodeAlreadyHandledOrExpired(ctx, event, e.Expiry, map[string]interface{}{"id": e.ID}, user.HumanPasswordlessInitCodeSentType)
if err != nil { if err != nil {
@ -442,7 +445,7 @@ func (u *userNotifier) reducePasswordlessCodeRequested(event eventstore.Event) (
e, e,
u.metricSuccessfulDeliveriesEmail, u.metricSuccessfulDeliveriesEmail,
u.metricFailedDeliveriesEmail, u.metricFailedDeliveriesEmail,
).SendPasswordlessRegistrationLink(notifyUser, origin, code, e.ID) ).SendPasswordlessRegistrationLink(notifyUser, origin, code, e.ID, e.URLTemplate)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -12,27 +12,6 @@ import (
) )
func TestNotify_SendEmailVerificationCode(t *testing.T) { 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 { type args struct {
user *query.NotifyUser user *query.NotifyUser
origin string origin string
@ -42,7 +21,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
args args args args
want *res want *notifyResult
wantErr error wantErr error
}{ }{
{ {
@ -56,7 +35,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
code: "123", code: "123",
urlTmpl: "", urlTmpl: "",
}, },
want: &res{ want: &notifyResult{
url: "https://example.com/ui/login/mail/verification?userID=user1&code=123&orgID=org1", url: "https://example.com/ui/login/mail/verification?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"}, args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType, messageType: domain.VerifyEmailMessageType,
@ -74,8 +53,8 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
code: "123", code: "123",
urlTmpl: "{{", urlTmpl: "{{",
}, },
want: &res{}, want: &notifyResult{},
wantErr: caos_errs.ThrowInvalidArgument(nil, "USERv2-ooD8p", "Errors.User.Email.InvalidURLTemplate"), wantErr: caos_errs.ThrowInvalidArgument(nil, "DOMAIN-oGh5e", "Errors.User.InvalidURLTemplate"),
}, },
{ {
name: "template success", name: "template success",
@ -88,7 +67,7 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
code: "123", code: "123",
urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}", urlTmpl: "https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}",
}, },
want: &res{ want: &notifyResult{
url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1", url: "https://example.com/email/verify?userID=user1&code=123&orgID=org1",
args: map[string]interface{}{"Code": "123"}, args: map[string]interface{}{"Code": "123"},
messageType: domain.VerifyEmailMessageType, messageType: domain.VerifyEmailMessageType,
@ -98,8 +77,8 @@ func TestNotify_SendEmailVerificationCode(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := new(res) got, notify := mockNotify()
err := notify(got).SendEmailVerificationCode(tt.args.user, tt.args.origin, tt.args.code, tt.args.urlTmpl) err := notify.SendEmailVerificationCode(tt.args.user, tt.args.origin, tt.args.code, tt.args.urlTmpl)
require.ErrorIs(t, err, tt.wantErr) require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got) assert.Equal(t, tt.want, got)
}) })

View File

@ -1,12 +1,24 @@
package types package types
import ( import (
"strings"
"github.com/zitadel/zitadel/internal/api/ui/login" "github.com/zitadel/zitadel/internal/api/ui/login"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
) )
func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, origin, code, codeID string) error { func (notify Notify) SendPasswordlessRegistrationLink(user *query.NotifyUser, origin, code, codeID, urlTmpl string) error {
url := domain.PasswordlessInitCodeLink(origin+login.HandlerPrefix+login.EndpointPasswordlessRegistration, user.ID, user.ResourceOwner, codeID, code) 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) return notify(url, nil, domain.PasswordlessRegistrationMessageType, true)
} }

View File

@ -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: &notifyResult{
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: &notifyResult{},
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: &notifyResult{
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)
})
}
}

View 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
}
}

View File

@ -315,9 +315,11 @@ func HumanPasswordlessInitCodeAddedEventMapper(event *repository.Event) (eventst
type HumanPasswordlessInitCodeRequestedEvent struct { type HumanPasswordlessInitCodeRequestedEvent struct {
eventstore.BaseEvent `json:"-"` eventstore.BaseEvent `json:"-"`
ID string `json:"id"` ID string `json:"id"`
Code *crypto.CryptoValue `json:"code"` Code *crypto.CryptoValue `json:"code"`
Expiry time.Duration `json:"expiry"` Expiry time.Duration `json:"expiry"`
URLTemplate string `json:"url_template,omitempty"`
CodeReturned bool `json:"code_returned,omitempty"`
} }
func (e *HumanPasswordlessInitCodeRequestedEvent) Data() interface{} { func (e *HumanPasswordlessInitCodeRequestedEvent) Data() interface{} {
@ -334,6 +336,8 @@ func NewHumanPasswordlessInitCodeRequestedEvent(
id string, id string,
code *crypto.CryptoValue, code *crypto.CryptoValue,
expiry time.Duration, expiry time.Duration,
urlTmpl string,
codeReturned bool,
) *HumanPasswordlessInitCodeRequestedEvent { ) *HumanPasswordlessInitCodeRequestedEvent {
return &HumanPasswordlessInitCodeRequestedEvent{ return &HumanPasswordlessInitCodeRequestedEvent{
BaseEvent: *eventstore.NewBaseEventForPush( BaseEvent: *eventstore.NewBaseEventForPush(
@ -341,9 +345,11 @@ func NewHumanPasswordlessInitCodeRequestedEvent(
aggregate, aggregate,
HumanPasswordlessInitCodeRequestedType, HumanPasswordlessInitCodeRequestedType,
), ),
ID: id, ID: id,
Code: code, Code: code,
Expiry: expiry, Expiry: expiry,
URLTemplate: urlTmpl,
CodeReturned: codeReturned,
} }
} }

View File

@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: Benutzer konnte in der gewünschten Organisation nicht gefunden werden NotFoundOnOrg: Benutzer konnte in der gewünschten Organisation nicht gefunden werden
NotAllowedOrg: Benutzer gehört nicht der benötigten Organisation an NotAllowedOrg: Benutzer gehört nicht der benötigten Organisation an
UserIDMissing: User ID fehlt UserIDMissing: User ID fehlt
UserIDWrong: "Der Anforderungsbenutzer ist nicht gleich dem authentifizierten Benutzer"
DomainPolicyNil: Organisation Policy ist leer DomainPolicyNil: Organisation Policy ist leer
EmailAsUsernameNotAllowed: Benutzername darf keine E-Mail Adresse sein EmailAsUsernameNotAllowed: Benutzername darf keine E-Mail Adresse sein
Invalid: Benutzerdaten sind ungültig Invalid: Benutzerdaten sind ungültig
@ -67,6 +68,7 @@ Errors:
NoChanges: Keine Änderungen gefunden NoChanges: Keine Änderungen gefunden
InitCodeNotFound: Kein Initialisierungs-Code gefunden InitCodeNotFound: Kein Initialisierungs-Code gefunden
UsernameNotChanged: Benutzername wurde nicht verändert UsernameNotChanged: Benutzername wurde nicht verändert
InvalidURLTemplate: URL Template ist ungültig
Profile: Profile:
NotFound: Profil nicht gefunden NotFound: Profil nicht gefunden
NotChanged: Profil nicht verändert NotChanged: Profil nicht verändert
@ -81,7 +83,6 @@ Errors:
NotChanged: Email wurde nicht geändert NotChanged: Email wurde nicht geändert
Empty: Email ist leer Empty: Email ist leer
IDMissing: Email ID fehlt IDMissing: Email ID fehlt
InvalidURLTemplate: URL Template ist ungültig
Phone: Phone:
NotFound: Telefonnummer nicht gefunden NotFound: Telefonnummer nicht gefunden
Invalid: Telefonnummer ist ungültig Invalid: Telefonnummer ist ungültig

View File

@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: User could not be found on chosen organization NotFoundOnOrg: User could not be found on chosen organization
NotAllowedOrg: User is no member of the required organization NotAllowedOrg: User is no member of the required organization
UserIDMissing: User ID missing UserIDMissing: User ID missing
UserIDWrong: "Request user not equal to authenticated user"
DomainPolicyNil: Organisation Policy is empty DomainPolicyNil: Organisation Policy is empty
EmailAsUsernameNotAllowed: Email is not allowed as username EmailAsUsernameNotAllowed: Email is not allowed as username
Invalid: Userdata is invalid Invalid: Userdata is invalid
@ -67,6 +68,7 @@ Errors:
NoChanges: No changes found NoChanges: No changes found
InitCodeNotFound: Initialization Code not found InitCodeNotFound: Initialization Code not found
UsernameNotChanged: Username not changed UsernameNotChanged: Username not changed
InvalidURLTemplate: URL Template is invalid
Profile: Profile:
NotFound: Profile not found NotFound: Profile not found
NotChanged: Profile not changed NotChanged: Profile not changed
@ -81,7 +83,6 @@ Errors:
NotChanged: Email not changed NotChanged: Email not changed
Empty: Email is empty Empty: Email is empty
IDMissing: Email ID is missing IDMissing: Email ID is missing
InvalidURLTemplate: URL Template is invalid
Phone: Phone:
NotFound: Phone not found NotFound: Phone not found
Invalid: Phone is invalid Invalid: Phone is invalid

View File

@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: El usuario no pudo encontrarse en la organización elegida NotFoundOnOrg: El usuario no pudo encontrarse en la organización elegida
NotAllowedOrg: El usuario no es miembro de la organización requerida NotAllowedOrg: El usuario no es miembro de la organización requerida
UserIDMissing: Falta el ID de usuario UserIDMissing: Falta el ID de usuario
UserIDWrong: "Solicitud de usuario no igual al usuario autenticado"
DomainPolicyNil: Falta la política de la organización DomainPolicyNil: Falta la política de la organización
EmailAsUsernameNotAllowed: La dirección de Email no se permite como nombre de usuario EmailAsUsernameNotAllowed: La dirección de Email no se permite como nombre de usuario
Invalid: Los datos de usuario no son válidos Invalid: Los datos de usuario no son válidos
@ -67,6 +68,7 @@ Errors:
NoChanges: No se encontraron cambios NoChanges: No se encontraron cambios
InitCodeNotFound: Código de inicialización no encontrado InitCodeNotFound: Código de inicialización no encontrado
UsernameNotChanged: El nombre de usuario no cambió UsernameNotChanged: El nombre de usuario no cambió
InvalidURLTemplate: La plantilla URL no es válida
Profile: Profile:
NotFound: Perfil no encontrado NotFound: Perfil no encontrado
NotChanged: El perfil no ha cambiado NotChanged: El perfil no ha cambiado
@ -81,7 +83,6 @@ Errors:
NotChanged: El email no ha cambiado NotChanged: El email no ha cambiado
Empty: El email no está vacío Empty: El email no está vacío
IDMissing: Falta el ID del email IDMissing: Falta el ID del email
InvalidURLTemplate: La plantilla URL no es válida
Phone: Phone:
NotFound: Teléfono no encontrado NotFound: Teléfono no encontrado
Invalid: El teléfono no es válido Invalid: El teléfono no es válido

View File

@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: L'utilisateur n'a pas été trouvé dans l'organisation choisie NotFoundOnOrg: L'utilisateur n'a pas été trouvé dans l'organisation choisie
NotAllowedOrg: L'utilisateur n'est pas membre de l'organisation requise NotAllowedOrg: L'utilisateur n'est pas membre de l'organisation requise
UserIDMissing: L'ID de l'utilisateur est manquant 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 DomainPolicyNil: La politique de l'organisation est vide
EmailAsUsernameNotAllowed: L'email n'est pas autorisé comme nom d'utilisateur EmailAsUsernameNotAllowed: L'email n'est pas autorisé comme nom d'utilisateur
Invalid: Les données de l'utilisateur ne sont pas valides Invalid: Les données de l'utilisateur ne sont pas valides
@ -67,6 +68,7 @@ Errors:
NoChanges: Aucun changement trouvé NoChanges: Aucun changement trouvé
InitCodeNotFound: Code d'initialisation non trouvé InitCodeNotFound: Code d'initialisation non trouvé
UsernameNotChanged: Nom d'utilisateur non modifié UsernameNotChanged: Nom d'utilisateur non modifié
InvalidURLTemplate: Le modèle d'URL n'est pas valide
Profile: Profile:
NotFound: Profil non trouvé NotFound: Profil non trouvé
NotChanged: Le profil n'a pas changé NotChanged: Le profil n'a pas changé
@ -81,7 +83,6 @@ Errors:
NotChanged: L'adresse électronique n'a pas changé NotChanged: L'adresse électronique n'a pas changé
Empty: Email est vide Empty: Email est vide
IDMissing: Email ID manquant IDMissing: Email ID manquant
InvalidURLTemplate: Le modèle d'URL n'est pas valide
Phone: Phone:
Notfound: Téléphone non trouvé Notfound: Téléphone non trouvé
Invalid: Le téléphone n'est pas valide Invalid: Le téléphone n'est pas valide

View File

@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: L'utente non è stato trovato nell'organizzazione scelta NotFoundOnOrg: L'utente non è stato trovato nell'organizzazione scelta
NotAllowedOrg: L'utente non è membro dell'organizzazione richiesta NotAllowedOrg: L'utente non è membro dell'organizzazione richiesta
UserIDMissing: ID utente mancante UserIDMissing: ID utente mancante
UserIDWrong: "Utente richiesta non uguale all'utente autenticato"
DomainPolicyNil: Impostazione Org IAM mancante DomainPolicyNil: Impostazione Org IAM mancante
EmailAsUsernameNotAllowed: L'e-mail non è consentita come nome utente EmailAsUsernameNotAllowed: L'e-mail non è consentita come nome utente
Invalid: I dati utente non sono validi Invalid: I dati utente non sono validi
@ -67,6 +68,7 @@ Errors:
NoChanges: Nessun cambiamento trovato NoChanges: Nessun cambiamento trovato
InitCodeNotFound: Codice di inizializzazione non trovato InitCodeNotFound: Codice di inizializzazione non trovato
UsernameNotChanged: Nome utente non cambiato UsernameNotChanged: Nome utente non cambiato
InvalidURLTemplate: Il modello di URL non è valido
Profile: Profile:
NotFound: Profilo non trovato NotFound: Profilo non trovato
NotChanged: Profilo non cambiato NotChanged: Profilo non cambiato
@ -81,7 +83,6 @@ Errors:
NotChanged: Email non cambiata NotChanged: Email non cambiata
Empty: Email è vuota Empty: Email è vuota
IDMissing: Email ID mancante IDMissing: Email ID mancante
InvalidURLTemplate: Il modello di URL non è valido
Phone: Phone:
NotFound: Telefono non trovato NotFound: Telefono non trovato
Invalid: Il telefono non è valido Invalid: Il telefono non è valido

View File

@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: ユーザーが選択した組織内で見つかりません NotFoundOnOrg: ユーザーが選択した組織内で見つかりません
NotAllowedOrg: ユーザーが必要な組織のメンバーでありません NotAllowedOrg: ユーザーが必要な組織のメンバーでありません
UserIDMissing: ユーザーIDがありません UserIDMissing: ユーザーIDがありません
UserIDWrong: "リクエストユーザーが認証されたユーザーと等しくない"
DomainPolicyNil: 組織ポリシーが空です DomainPolicyNil: 組織ポリシーが空です
EmailAsUsernameNotAllowed: メールアドレスはユーザー名として使用できません EmailAsUsernameNotAllowed: メールアドレスはユーザー名として使用できません
Invalid: 無効なユーザーデータです Invalid: 無効なユーザーデータです
@ -67,6 +68,7 @@ Errors:
NoChanges: 変更は見つかりません NoChanges: 変更は見つかりません
InitCodeNotFound: 初期化コードが見つかりません InitCodeNotFound: 初期化コードが見つかりません
UsernameNotChanged: ユーザー名は変更されていません UsernameNotChanged: ユーザー名は変更されていません
InvalidURLTemplate: URLテンプレートが無効です
Profile: Profile:
NotFound: プロファイルが見つかりません NotFound: プロファイルが見つかりません
NotChanged: プロファイルが変更されていません NotChanged: プロファイルが変更されていません
@ -76,7 +78,6 @@ Errors:
Invalid: 無効なメールアドレスです Invalid: 無効なメールアドレスです
AlreadyVerified: メールアドレスはすでに検証済みです AlreadyVerified: メールアドレスはすでに検証済みです
NotChanged: メールアドレスが変更されていません NotChanged: メールアドレスが変更されていません
InvalidURLTemplate: URLテンプレートが無効です
Phone: Phone:
NotFound: 電話番号が見つかりません NotFound: 電話番号が見つかりません
Invalid: 無効な電話番号です Invalid: 無効な電話番号です

View File

@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: Użytkownik nie został znaleziony w wybranej organizacji NotFoundOnOrg: Użytkownik nie został znaleziony w wybranej organizacji
NotAllowedOrg: Użytkownik nie jest członkiem wymaganej organizacji NotAllowedOrg: Użytkownik nie jest członkiem wymaganej organizacji
UserIDMissing: Brakuje ID użytkownika UserIDMissing: Brakuje ID użytkownika
UserIDWrong: "Żądanie użytkownika nie jest równe uwierzytelnionemu użytkownikowi"
DomainPolicyNil: Polityka organizacji jest pusta DomainPolicyNil: Polityka organizacji jest pusta
EmailAsUsernameNotAllowed: Adres e-mail nie jest dozwolony jako nazwa użytkownika EmailAsUsernameNotAllowed: Adres e-mail nie jest dozwolony jako nazwa użytkownika
Invalid: Dane użytkownika są nieprawidłowe Invalid: Dane użytkownika są nieprawidłowe
@ -67,6 +68,7 @@ Errors:
NoChanges: Nie znaleziono zmian NoChanges: Nie znaleziono zmian
InitCodeNotFound: Kod inicjalizacji nie znaleziony InitCodeNotFound: Kod inicjalizacji nie znaleziony
UsernameNotChanged: Nazwa użytkownika nie została zmieniona UsernameNotChanged: Nazwa użytkownika nie została zmieniona
InvalidURLTemplate: Szablon URL jest nieprawidłowy
Profile: Profile:
NotFound: Profil nie znaleziony NotFound: Profil nie znaleziony
NotChanged: Profil nie zmieniony NotChanged: Profil nie zmieniony
@ -81,7 +83,6 @@ Errors:
NotChanged: Adres e-mail nie zmieniony NotChanged: Adres e-mail nie zmieniony
Empty: Adres e-mail jest pusty Empty: Adres e-mail jest pusty
IDMissing: Adres e-mail ID brakuje IDMissing: Adres e-mail ID brakuje
InvalidURLTemplate: Szablon URL jest nieprawidłowy
Phone: Phone:
NotFound: Numer telefonu nie znaleziony NotFound: Numer telefonu nie znaleziony
Invalid: Numer telefonu jest nieprawidłowy Invalid: Numer telefonu jest nieprawidłowy

View File

@ -53,6 +53,7 @@ Errors:
NotFoundOnOrg: 在所选组织中找不到用户 NotFoundOnOrg: 在所选组织中找不到用户
NotAllowedOrg: 用户不是所需组织的成员 NotAllowedOrg: 用户不是所需组织的成员
UserIDMissing: 缺少用户 ID UserIDMissing: 缺少用户 ID
UserIDWrong: "请求用户不等于经过身份验证的用户"
DomainPolicyNil: 组织策略为空 DomainPolicyNil: 组织策略为空
EmailAsUsernameNotAllowed: 电子邮件不允许作为用户名 EmailAsUsernameNotAllowed: 电子邮件不允许作为用户名
Invalid: 用户数据无效 Invalid: 用户数据无效
@ -67,6 +68,7 @@ Errors:
NoChanges: 未发现任何更改 NoChanges: 未发现任何更改
InitCodeNotFound: 未找到初始化验证码 InitCodeNotFound: 未找到初始化验证码
UsernameNotChanged: 用户名未更改 UsernameNotChanged: 用户名未更改
InvalidURLTemplate: URL模板无效
Profile: Profile:
NotFound: 未找到个人资料 NotFound: 未找到个人资料
NotChanged: 个人资料未更改 NotChanged: 个人资料未更改
@ -81,7 +83,6 @@ Errors:
NotChanged: 电子邮件未更改 NotChanged: 电子邮件未更改
Empty: 电子邮件是空的 Empty: 电子邮件是空的
IDMissing: 电子邮件ID丢失 IDMissing: 电子邮件ID丢失
InvalidURLTemplate: URL模板无效
Phone: Phone:
NotFound: 手机号码未找到 NotFound: 手机号码未找到
Invalid: 手机号码无效 Invalid: 手机号码无效

View 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
}

View File

@ -1,8 +1,8 @@
package webauthn package webauthn
import ( import (
"github.com/duo-labs/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/duo-labs/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
) )

View File

@ -5,8 +5,8 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"github.com/duo-labs/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/duo-labs/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"github.com/zitadel/logging" "github.com/zitadel/logging"
"github.com/zitadel/zitadel/internal/api/authz" "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) { func (w *Config) serverFromContext(ctx context.Context) (*webauthn.WebAuthn, error) {
instance := authz.GetInstance(ctx) instance := authz.GetInstance(ctx)
return webauthn.New(&webauthn.Config{ webAuthn, err := webauthn.New(&webauthn.Config{
RPDisplayName: w.DisplayName, RPDisplayName: w.DisplayName,
RPID: instance.RequestedDomain(), 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
} }

View 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)
}
})
}
}

View File

@ -20,7 +20,7 @@ message SendPasskeyRegistrationLink {
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
min_length: 1; min_length: 1;
max_length: 200; 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.\"" 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.\""
} }
]; ];