Merge branch 'usage-telemetry' of github.com:zitadel/zitadel into usage-telemetry

This commit is contained in:
Elio Bischof 2023-06-16 14:48:10 +02:00
commit 0dd50018ef
No known key found for this signature in database
GPG Key ID: 7B383FDE4DDBF1BD
31 changed files with 793 additions and 74 deletions

View File

@ -23,10 +23,12 @@ type FirstInstance struct {
DefaultLanguage language.Tag
Org command.OrgSetup
MachineKeyPath string
PatPath string
instanceSetup command.InstanceSetup
userEncryptionKey *crypto.KeyConfig
smtpEncryptionKey *crypto.KeyConfig
oidcEncryptionKey *crypto.KeyConfig
masterKey string
db *sql.DB
es *eventstore.Eventstore
@ -59,6 +61,14 @@ func (mig *FirstInstance) Execute(ctx context.Context) error {
return err
}
if err = verifyKey(mig.oidcEncryptionKey, keyStorage); err != nil {
return err
}
oidcEncryption, err := crypto.NewAESCrypto(mig.oidcEncryptionKey, keyStorage)
if err != nil {
return err
}
cmd, err := command.StartCommands(mig.es,
mig.defaults,
mig.zitadelRoles,
@ -73,13 +83,12 @@ func (mig *FirstInstance) Execute(ctx context.Context) error {
nil,
userAlg,
nil,
nil,
oidcEncryption,
nil,
nil,
nil,
nil,
)
if err != nil {
return err
}
@ -101,25 +110,43 @@ func (mig *FirstInstance) Execute(ctx context.Context) error {
}
}
_, _, key, _, err := cmd.SetUpInstance(ctx, &mig.instanceSetup)
if key == nil {
_, token, key, _, err := cmd.SetUpInstance(ctx, &mig.instanceSetup)
if err != nil {
return err
}
if mig.instanceSetup.Org.Machine != nil &&
((mig.instanceSetup.Org.Machine.Pat != nil && token == "") ||
(mig.instanceSetup.Org.Machine.MachineKey != nil && key == nil)) {
return err
}
if key != nil {
keyDetails, err := key.Detail()
if err != nil {
return err
}
if err := outputStdoutOrPath(mig.MachineKeyPath, string(keyDetails)); err != nil {
return err
}
}
if token != "" {
if err := outputStdoutOrPath(mig.PatPath, token); err != nil {
return err
}
}
return nil
}
func outputStdoutOrPath(path string, content string) (err error) {
f := os.Stdout
if mig.MachineKeyPath != "" {
f, err = os.OpenFile(mig.MachineKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if path != "" {
f, err = os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer f.Close()
}
keyDetails, err := key.Detail()
if err != nil {
return err
}
_, err = fmt.Fprintln(f, string(keyDetails))
_, err = fmt.Fprintln(f, content)
return err
}

View File

@ -72,6 +72,7 @@ type Steps struct {
type encryptionKeyConfig struct {
User *crypto.KeyConfig
SMTP *crypto.KeyConfig
OIDC *crypto.KeyConfig
}
func MustNewSteps(v *viper.Viper) *Steps {

View File

@ -75,6 +75,7 @@ func Setup(config *Config, steps *Steps, masterKey string) {
steps.FirstInstance.instanceSetup = config.DefaultInstance
steps.FirstInstance.userEncryptionKey = config.EncryptionKeys.User
steps.FirstInstance.smtpEncryptionKey = config.EncryptionKeys.SMTP
steps.FirstInstance.oidcEncryptionKey = config.EncryptionKeys.OIDC
steps.FirstInstance.masterKey = masterKey
steps.FirstInstance.db = dbClient.DB
steps.FirstInstance.es = eventstoreClient

View File

@ -1,5 +1,6 @@
FirstInstance:
MachineKeyPath:
PatPath:
InstanceName: ZITADEL
DefaultLanguage: en
Org:
@ -30,6 +31,8 @@ FirstInstance:
MachineKey:
ExpirationDate:
Type:
Pat:
ExpirationDate:
CorrectCreationDate:
FailAfter: 5m

View File

@ -1,16 +1,33 @@
import { Directive, ElementRef, HostListener, Renderer2 } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { take } from 'rxjs';
import { NavigationService } from 'src/app/services/navigation.service';
@Directive({
selector: '[cnslBack]',
})
export class BackDirective {
new: Boolean = false;
@HostListener('click')
onClick(): void {
this.navigation.back();
// Go back again to avoid create dialog starts again
if (this.new) {
this.navigation.back();
}
}
constructor(private navigation: NavigationService, private elRef: ElementRef, private renderer2: Renderer2) {
constructor(
private navigation: NavigationService,
private elRef: ElementRef,
private renderer2: Renderer2,
private route: ActivatedRoute,
) {
// Check if a new element was created using a create dialog
this.route.queryParams.pipe(take(1)).subscribe((params) => {
this.new = params['new'];
});
if (navigation.isBackPossible) {
// this.renderer2.removeStyle(this.elRef.nativeElement, 'visibility');
} else {

View File

@ -378,7 +378,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
if (resp.clientId || resp.clientSecret) {
this.showSavedDialog(resp);
} else {
this.router.navigate(['projects', this.projectId, 'apps', resp.appId]);
this.router.navigate(['projects', this.projectId, 'apps', resp.appId], { queryParams: { new: true } });
}
})
.catch((error) => {
@ -396,7 +396,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
if (resp.clientId || resp.clientSecret) {
this.showSavedDialog(resp);
} else {
this.router.navigate(['projects', this.projectId, 'apps', resp.appId]);
this.router.navigate(['projects', this.projectId, 'apps', resp.appId], { queryParams: { new: true } });
}
})
.catch((error) => {
@ -410,7 +410,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
.addSAMLApp(this.samlAppRequest)
.then((resp) => {
this.loading = false;
this.router.navigate(['projects', this.projectId, 'apps', resp.appId]);
this.router.navigate(['projects', this.projectId, 'apps', resp.appId], { queryParams: { new: true } });
})
.catch((error) => {
this.loading = false;
@ -436,7 +436,7 @@ export class AppCreateComponent implements OnInit, OnDestroy {
});
dialogRef.afterClosed().subscribe(() => {
this.router.navigate(['projects', this.projectId, 'apps', added.appId]);
this.router.navigate(['projects', this.projectId, 'apps', added.appId], { queryParams: { new: true } });
});
}

View File

@ -88,7 +88,7 @@ export class ProjectRoleCreateComponent implements OnInit, OnDestroy {
.bulkAddProjectRoles(this.projectId, rolesToAdd)
.then(() => {
this.toast.showInfo('PROJECT.TOAST.ROLESCREATED', true);
this.router.navigate(['projects', this.projectId], { queryParams: { id: 'roles' } });
this.router.navigate(['projects', this.projectId], { queryParams: { id: 'roles', new: true } });
})
.catch((error) => {
this.toast.showError(error);

View File

@ -33,7 +33,7 @@ export class ProjectCreateComponent {
.addProject(this.project)
.then((resp: AddProjectResponse.AsObject) => {
this.toast.showInfo('PROJECT.TOAST.CREATED', true);
this.router.navigate(['projects', resp.id]);
this.router.navigate(['projects', resp.id], { queryParams: { new: true } });
})
.catch((error) => {
this.toast.showError(error);

View File

@ -71,7 +71,7 @@ export class UserCreateMachineComponent implements OnDestroy {
this.toast.showInfo('USER.TOAST.CREATED', true);
const id = data.userId;
if (id) {
this.router.navigate(['users', id]);
this.router.navigate(['users', id], { queryParams: { new: true } });
}
})
.catch((error: any) => {

View File

@ -183,7 +183,7 @@ export class UserCreateComponent implements OnInit, OnDestroy {
.then((data) => {
this.loading = false;
this.toast.showInfo('USER.TOAST.CREATED', true);
this.router.navigate(['users', data.userId]);
this.router.navigate(['users', data.userId], { queryParams: { new: true } });
})
.catch((error) => {
this.loading = false;

View File

@ -17,7 +17,7 @@ Last revised: June 14, 2022
**Downtime** means any period of time in which Core Services are not Available within the Region of the customers organization. Downtime excludes any time in which ZITADEL Cloud is not Available because of
- Announced maintenance work
- [Announced maintenance work](/docs/support/software-release-cycles-support#maintenance)
- Emergency maintenance
- Force majeure events.

View File

@ -154,7 +154,7 @@ Projections:
RequeueEvery: 300s
```
### Manage your Data
### Manage your data
When designing your backup strategy,
it is worth knowing that
@ -173,7 +173,7 @@ please refer to the corresponding docs
or [for PostgreSQL](https://www.postgresql.org/docs/current/admin.html).
## Data Initialization
## Data initialization
- You can configure instance defaults in the DefaultInstance section.
If you plan to eventually create [multiple virtual instances](/concepts/structure/instance#multiple-virtual-instances), these defaults take effect.
@ -210,3 +210,19 @@ DefaultInstance:
If you host ZITADEL as a service,
you might want to [limit usage and/or execute tasks on certain usage units and levels](/self-hosting/manage/quotas).
## Minimum system requirements
### General resource usage
ZITADEL consumes around 512MB RAM and can run with less than 1 CPU core.
The database consumes around 2 CPU under normal conditions and 6GB RAM with some caching to it.
:::info Password hashing
Be aware of CPU spikes when hashing passwords. We recommend to have 4 CPU cores available for this purpose.
:::
### Production HA cluster
It is recommended to build a minimal high-availability with 3 Nodes with 4 CPU and 16GB memory each.
Excluding non-essential services, such as log collection, metrics etc, the resources could be reduced to around 4 CPU and 8GB memory each.

View File

@ -1,10 +1,10 @@
---
title: Support States & Software Release Cycle
title: Support states & software release cycle
---
## Support States
## Support states
It's important to note that support may differ depending on the feature, and not all features may be fully supported.
It's important to note that support may differ depending on the feature, and not all features may be fully supported.
We always strive to provide the best support possible for our customers and community,
but we may not be able to provide immediate or comprehensive support for all features.
Also the support may differ depending on your contracts. Read more about it on our [Legal page](/docs/legal)
@ -21,7 +21,7 @@ In case you are eligible to [support services](/docs/legal/support-services) get
Please report any security issues immediately to the indicated address in our [security.txt](https://zitadel.com/.well-known/security.txt)
:::
### Enterprise Supported
### Enterprise supported
Enterprise supported features are those where we provide support only to users eligible for enterprise [support services](/docs/legal/support-services).
These features should be functional for eligible users, but may have some limitations for a broader use.
@ -34,39 +34,94 @@ If you encounter issues with an enterprise supported feature and you are eligibl
- LDAP Identity Provider
- [Terraform Provider](https://github.com/zitadel/terraform-provider-zitadel)
### Community Supported
### Community supported
Community supported features are those that have been developed by our community and may not have undergone extensive testing or support from our team.
Community supported features are those that have been developed by our community and may not have undergone extensive testing or support from our team.
If you encounter issues with a community supported feature, we encourage you to seek help from our community or other online resources, where other users can provide assistance:
- Join our [Discord Chat](https://zitadel.com/chat)
- Search [Github Issues](https://github.com/search?q=org%3Azitadel+&type=issues) and report a new issue
- Search [Github Discussions](https://github.com/search?q=org%3Azitadel+&type=discussions) and open a new discussion as question or idea
## Software Release Cycle
## Software release cycle
It's important to note that both Alpha and Beta software can have breaking changes, meaning they are not backward-compatible with previous versions of the software.
Therefore, it's recommended to use caution when using Alpha and Beta software, and to always back up important data before installing or testing new software versions.
Only features in General Availability will be covered by support services.
We encourage our community to test Alpha and Beta software and provide feedback via our [Discord Chat](https://zitadel.com/chat).
We encourage our community to test Alpha and Beta software and provide feedback via our [Discord Chat](https://zitadel.com/chat).
### Alpha
The Alpha state is our initial testing phase.
The Alpha state is our initial testing phase.
It is available to everyone, but it is not yet complete and may contain bugs and incomplete features.
We recommend that users exercise caution when using Alpha software and avoid using it for critical tasks, as support is limited during this phase.
### Beta
The Beta state comes after the Alpha phase and is a more stable version of the software.
The Beta state comes after the Alpha phase and is a more stable version of the software.
It is feature-complete, but may still contain bugs that need to be fixed before general availability.
While it is available to everyone, we recommend that users exercise caution when using Beta software and avoid using it for critical tasks.
During this phase, support is limited as we focus on testing and bug fixing.
While it is available to everyone, we recommend that users exercise caution when using Beta software and avoid using it for critical tasks.
During this phase, support is limited as we focus on testing and bug fixing.
### General Available
### General available
Generally available features are available to everyone and have the appropriate test coverage to be used for critical tasks.
The software will be backwards-compatible with previous versions, for exceptions we will publish a [technical advisory](https://zitadel.com/docs/support/technical_advisory).
Features in General Availability are not marked explicitly.
## Release types
All release channels receive regular updates and bug fixes.
However, the timing and frequency of updates may differ between the channels.
The choice between the "release candidate", "latest" and "stable" release channels depends on the specific requirements, preferences, and risk tolerance of the users.
[List of all releases](https://github.com/zitadel/zitadel/releases)
### Release candidate
A release candidate refers to a pre-release version that is distributed to a limited group of users or customers for testing and evaluation purposes before a wider release.
It allows a selected group, such as our open source community or early adopters, to provide valuable feedback, identify potential issues, and help refine the software.
Please note that since it is not the final version, the release candidate may still contain some bugs or issues that are addressed before the official release.
Release candidates are accessible for our open source community, but will not be deployed to the ZITADEL Cloud Platform.
### Latest
The "latest" release channel is designed for users who prefer to access the most recent updates, features, and enhancements as soon as they become available.
It provides early access to new functionalities and improvements but may involve a higher degree of risk as it is the most actively developed version.
Users opting for the latest release channel should be aware that occasional bugs or issues may arise due to the ongoing development process.
### Stable
The "stable" release channel is intended for users seeking a more reliable and production-ready version of the software.
It offers a well-tested and validated release with fewer known issues and a higher level of stability.
The stable release channel undergoes rigorous quality assurance and testing processes to ensure that it meets the highest standards of reliability and performance.
It is recommended for users who prioritize stability over immediate access to the latest features.
Current Stable Version:
```yaml reference
https://github.com/zitadel/zitadel/blob/main/release-channels.yaml
```
## Maintenance
ZITADEL Cloud follows a regular deployment cycle to ensure our product remains up-to-date, secure, and provides new features.
Our standard deployment cycle occurs every two weeks, during which we implement updates, bug fixes, and enhancements to improve the functionality and performance of our product.
In certain circumstances, we may require additional deployments beyond the regular two-week cycle.
This can occur for example when we have substantial updates or feature releases that require additional time for thorough testing and implementation or security fixes.
During deployments, we strive to minimize any disruptions and do not expect any downtime.
### Release deployment with risk of downtime
In rare situations where deploying releases that may carry a risk of increased latency or short downtime, we have a well-defined procedure in place to ensure transparent communication.
Prior to such deployments, we publish information on our status page, which can be accessed by visiting [https://status.zitadel.com/](https://status.zitadel.com/).
We also recommend that you subscribe to those updates on the [status page](https://status.zitadel.com/).
We make it a priority to inform you of any potential impact well in advance.
In adherence to our commitment to transparency, we provide a minimum notice period of five working days before deploying a release that poses a risk of downtime.
This gives you time to plan accordingly, make any necessary adjustments, or reach out to our support team for assistance.
Our team works diligently to minimize the risk of downtime during these releases. We thoroughly test and verify each update before deployment to ensure the highest level of stability and reliability.

View File

@ -27,7 +27,7 @@ var (
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, errCtx, cancel := integration.Contexts(time.Hour)
ctx, errCtx, cancel := integration.Contexts(5 * time.Minute)
defer cancel()
Tester = integration.NewTester(ctx)
@ -55,7 +55,7 @@ retry:
s = resp.GetSession()
break retry
}
if status.Convert(err).Code() == codes.NotFound {
if code := status.Convert(err).Code(); code == codes.NotFound || code == codes.PermissionDenied {
select {
case <-CTX.Done():
t.Fatal(CTX.Err(), err)

View File

@ -113,13 +113,11 @@ func createInstancePbToAddMachine(req *system_pb.CreateInstanceRequest_Machine,
// Scopes are currently static and can not be overwritten
Scopes: []string{oidc.ScopeOpenID, z_oidc.ScopeUserMetaData, z_oidc.ScopeResourceOwner},
}
if !defaultMachine.Pat.ExpirationDate.IsZero() {
pat.ExpirationDate = defaultMachine.Pat.ExpirationDate
} else if req.PersonalAccessToken.ExpirationDate.IsValid() {
if req.GetPersonalAccessToken().GetExpirationDate().IsValid() {
pat.ExpirationDate = req.PersonalAccessToken.ExpirationDate.AsTime()
} else if defaultMachine.Pat != nil && !defaultMachine.Pat.ExpirationDate.IsZero() {
pat.ExpirationDate = defaultMachine.Pat.ExpirationDate
}
machine.Pat = &pat
}

View File

@ -3,13 +3,13 @@ package user
import (
"context"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
"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"
object_pb "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
@ -41,24 +41,32 @@ func passkeyAuthenticatorToDomain(pa user.PasskeyAuthenticator) domain.Authentic
}
}
func passkeyRegistrationDetailsToPb(details *domain.PasskeyRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) {
func webAuthNRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*object_pb.Details, *structpb.Struct, error) {
if err != nil {
return nil, nil, err
}
options := new(structpb.Struct)
if err := options.UnmarshalJSON(details.PublicKeyCredentialCreationOptions); err != nil {
return nil, nil, caos_errs.ThrowInternal(err, "USERv2-Dohr6", "Errors.Internal")
}
return object.DomainToDetailsPb(details.ObjectDetails), options, nil
}
func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) {
objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err)
if err != nil {
return nil, err
}
options := new(structpb.Struct)
if err := protojson.Unmarshal(details.PublicKeyCredentialCreationOptions, options); err != nil {
return nil, caos_errs.ThrowInternal(err, "USERv2-Dohr6", "Errors.Internal")
}
return &user.RegisterPasskeyResponse{
Details: object.DomainToDetailsPb(details.ObjectDetails),
PasskeyId: details.PasskeyID,
Details: objectDetails,
PasskeyId: details.ID,
PublicKeyCredentialCreationOptions: options,
}, nil
}
func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) {
resourceOwner := authz.GetCtxData(ctx).ResourceOwner
pkc, err := protojson.Marshal(req.GetPublicKeyCredential())
pkc, err := req.GetPublicKeyCredential().MarshalJSON()
if err != nil {
return nil, caos_errs.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal")
}

View File

@ -94,7 +94,8 @@ func TestServer_RegisterPasskey(t *testing.T) {
},
wantErr: true,
},
/* TODO after we are able to obtain a Bearer token for a human user
/* TODO: after we are able to obtain a Bearer token for a human user
https://github.com/zitadel/zitadel/issues/6022
{
name: "human user",
args: args{

View File

@ -50,7 +50,7 @@ func Test_passkeyAuthenticatorToDomain(t *testing.T) {
func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
type args struct {
details *domain.PasskeyRegistrationDetails
details *domain.WebAuthNRegistrationDetails
err error
}
tests := []struct {
@ -70,13 +70,13 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
{
name: "unmarshall error",
args: args{
details: &domain.PasskeyRegistrationDetails{
details: &domain.WebAuthNRegistrationDetails{
ObjectDetails: &domain.ObjectDetails{
Sequence: 22,
EventDate: time.Unix(3000, 22),
ResourceOwner: "me",
},
PasskeyID: "123",
ID: "123",
PublicKeyCredentialCreationOptions: []byte(`\\`),
},
err: nil,
@ -86,13 +86,13 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
{
name: "ok",
args: args{
details: &domain.PasskeyRegistrationDetails{
details: &domain.WebAuthNRegistrationDetails{
ObjectDetails: &domain.ObjectDetails{
Sequence: 22,
EventDate: time.Unix(3000, 22),
ResourceOwner: "me",
},
PasskeyID: "123",
ID: "123",
PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`),
},
err: nil,

View File

@ -0,0 +1,44 @@
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) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) {
return u2fRegistrationDetailsToPb(
s.command.RegisterUserU2F(ctx, req.GetUserId(), authz.GetCtxData(ctx).ResourceOwner),
)
}
func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterU2FResponse, error) {
objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err)
if err != nil {
return nil, err
}
return &user.RegisterU2FResponse{
Details: objectDetails,
U2FId: details.ID,
PublicKeyCredentialCreationOptions: options,
}, nil
}
func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FRegistrationRequest) (*user.VerifyU2FRegistrationResponse, error) {
resourceOwner := authz.GetCtxData(ctx).ResourceOwner
pkc, err := req.GetPublicKeyCredential().MarshalJSON()
if err != nil {
return nil, caos_errs.ThrowInternal(err, "USERv2-IeTh4", "Errors.Internal")
}
objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.GetUserId(), resourceOwner, req.GetTokenName(), "", pkc)
if err != nil {
return nil, err
}
return &user.VerifyU2FRegistrationResponse{
Details: object.DomainToDetailsPb(objectDetails),
}, nil
}

View File

@ -0,0 +1,167 @@
//go:build integration
package user_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
"github.com/zitadel/zitadel/internal/integration"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func TestServer_RegisterU2F(t *testing.T) {
userID := Tester.CreateHumanUser(CTX).GetUserId()
type args struct {
ctx context.Context
req *user.RegisterU2FRequest
}
tests := []struct {
name string
args args
want *user.RegisterU2FResponse
wantErr bool
}{
{
name: "missing user id",
args: args{
ctx: CTX,
req: &user.RegisterU2FRequest{},
},
wantErr: true,
},
{
name: "user mismatch",
args: args{
ctx: CTX,
req: &user.RegisterU2FRequest{
UserId: userID,
},
},
wantErr: true,
},
/* TODO: after we are able to obtain a Bearer token for a human user
https://github.com/zitadel/zitadel/issues/6022
{
name: "human user",
args: args{
ctx: CTX,
req: &user.RegisterU2FRequest{
UserId: userID,
},
},
want: &user.RegisterU2FResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
*/
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.RegisterU2F(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.GetU2FId())
assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions())
_, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions())
require.NoError(t, err)
}
})
}
}
func TestServer_VerifyU2FRegistration(t *testing.T) {
userID := Tester.CreateHumanUser(CTX).GetUserId()
/* TODO after we are able to obtain a Bearer token for a human user
pkr, err := Client.RegisterU2F(CTX, &user.RegisterU2FRequest{
UserId: userID,
})
require.NoError(t, err)
require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
require.NoError(t, err)
*/
type args struct {
ctx context.Context
req *user.VerifyU2FRegistrationRequest
}
tests := []struct {
name string
args args
want *user.VerifyU2FRegistrationResponse
wantErr bool
}{
{
name: "missing user id",
args: args{
ctx: CTX,
req: &user.VerifyU2FRegistrationRequest{
U2FId: "123",
TokenName: "nice name",
},
},
wantErr: true,
},
/* TODO after we are able to obtain a Bearer token for a human user
{
name: "success",
args: args{
ctx: CTX,
req: &user.VerifyU2FRegistrationRequest{
UserId: userID,
U2FId: pkr.GetU2FId(),
PublicKeyCredential: attestationResponse,
TokenName: "nice name",
},
},
want: &user.VerifyU2FRegistrationResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
*/
{
name: "wrong credential",
args: args{
ctx: CTX,
req: &user.VerifyU2FRegistrationRequest{
UserId: userID,
U2FId: "123",
PublicKeyCredential: &structpb.Struct{
Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
},
TokenName: "nice name",
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.VerifyU2FRegistration(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)
})
}
}

View File

@ -0,0 +1,97 @@
package user
import (
"io"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
func Test_u2fRegistrationDetailsToPb(t *testing.T) {
type args struct {
details *domain.WebAuthNRegistrationDetails
err error
}
tests := []struct {
name string
args args
want *user.RegisterU2FResponse
wantErr error
}{
{
name: "an error",
args: args{
details: nil,
err: io.ErrClosedPipe,
},
wantErr: io.ErrClosedPipe,
},
{
name: "unmarshall error",
args: args{
details: &domain.WebAuthNRegistrationDetails{
ObjectDetails: &domain.ObjectDetails{
Sequence: 22,
EventDate: time.Unix(3000, 22),
ResourceOwner: "me",
},
ID: "123",
PublicKeyCredentialCreationOptions: []byte(`\\`),
},
err: nil,
},
wantErr: caos_errs.ThrowInternal(nil, "USERv2-Dohr6", "Errors.Internal"),
},
{
name: "ok",
args: args{
details: &domain.WebAuthNRegistrationDetails{
ObjectDetails: &domain.ObjectDetails{
Sequence: 22,
EventDate: time.Unix(3000, 22),
ResourceOwner: "me",
},
ID: "123",
PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`),
},
err: nil,
},
want: &user.RegisterU2FResponse{
Details: &object.Details{
Sequence: 22,
ChangeDate: &timestamppb.Timestamp{
Seconds: 3000,
Nanos: 22,
},
ResourceOwner: "me",
},
U2FId: "123",
PublicKeyCredentialCreationOptions: &structpb.Struct{
Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := u2fRegistrationDetailsToPb(tt.args.details, tt.args.err)
require.ErrorIs(t, err, tt.wantErr)
if !proto.Equal(tt.want, got) {
t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got)
}
if tt.want != nil {
grpc.AllFieldsSet(t, got.ProtoReflect())
}
})
}
}

View File

@ -53,6 +53,9 @@ func (p *Storage) GetEntityByID(ctx context.Context, entityID string) (*servicep
if err != nil {
return nil, err
}
if app.State != domain.AppStateActive {
return nil, errors.ThrowPreconditionFailed(nil, "SAML-sdaGg", "app is not active")
}
return serviceprovider.NewServiceProvider(
app.ID,
&serviceprovider.Config{
@ -67,6 +70,9 @@ func (p *Storage) GetEntityIDByAppID(ctx context.Context, appID string) (string,
if err != nil {
return "", err
}
if app.State != domain.AppStateActive {
return "", errors.ThrowPreconditionFailed(nil, "SAML-sdaGg", "app is not active")
}
return app.SAMLConfig.EntityID, nil
}

View File

@ -288,18 +288,18 @@ func (l *Login) handleExternalUserAuthenticated(
externalUser := mapIDPUserToExternalUser(user, provider.ID)
// check and fill in local linked user
externalErr := l.authRepo.CheckExternalUserLogin(setContext(r.Context(), ""), authReq.ID, authReq.AgentID, externalUser, domain.BrowserInfoFromRequest(r))
if !errors.IsNotFound(externalErr) {
if externalErr != nil && !errors.IsNotFound(externalErr) {
l.renderError(w, r, authReq, externalErr)
return
}
externalUser, externalUserChange, err := l.runPostExternalAuthenticationActions(externalUser, tokens(session), authReq, r, user, externalErr)
externalUser, externalUserChange, err := l.runPostExternalAuthenticationActions(externalUser, tokens(session), authReq, r, user, nil)
if err != nil {
l.renderError(w, r, authReq, err)
return
}
// if action is done and no user linked then link or register
if errors.IsNotFound(externalErr) {
l.externalUserNotExisting(w, r, authReq, provider, externalUser)
l.externalUserNotExisting(w, r, authReq, provider, externalUser, externalUserChange)
return
}
if provider.IsAutoUpdate || len(externalUser.Metadatas) > 0 || externalUserChange {
@ -334,7 +334,7 @@ func (l *Login) handleExternalUserAuthenticated(
// * external not found overview:
// - creation by user
// - linking to existing user
func (l *Login) externalUserNotExisting(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, provider *query.IDPTemplate, externalUser *domain.ExternalUser) {
func (l *Login) externalUserNotExisting(w http.ResponseWriter, r *http.Request, authReq *domain.AuthRequest, provider *query.IDPTemplate, externalUser *domain.ExternalUser, changed bool) {
resourceOwner := authz.GetInstance(r.Context()).DefaultOrganisationID()
if authReq.RequestedOrgID != "" && authReq.RequestedOrgID != resourceOwner {
@ -360,6 +360,12 @@ func (l *Login) externalUserNotExisting(w http.ResponseWriter, r *http.Request,
l.renderExternalNotFoundOption(w, r, authReq, orgIAMPolicy, human, idpLink, err)
return
}
if changed {
if err := l.authRepo.SetLinkingUser(r.Context(), authReq, externalUser); err != nil {
l.renderError(w, r, authReq, err)
return
}
}
l.autoCreateExternalUser(w, r, authReq)
}

View File

@ -17,6 +17,7 @@ type AuthRequestRepository interface {
CheckLoginName(ctx context.Context, id, loginName, userAgentID string) error
CheckExternalUserLogin(ctx context.Context, authReqID, userAgentID string, user *domain.ExternalUser, info *domain.BrowserInfo) error
SetExternalUserLogin(ctx context.Context, authReqID, userAgentID string, user *domain.ExternalUser) error
SetLinkingUser(ctx context.Context, request *domain.AuthRequest, externalUser *domain.ExternalUser) error
SelectUser(ctx context.Context, id, userID, userAgentID string) error
SelectExternalIDP(ctx context.Context, authReqID, idpConfigID, userAgentID string) error
VerifyPassword(ctx context.Context, id, userID, resourceOwner, password, userAgentID string, info *domain.BrowserInfo) error

View File

@ -278,6 +278,16 @@ func (repo *AuthRequestRepo) SetExternalUserLogin(ctx context.Context, authReqID
return repo.AuthRequests.UpdateAuthRequest(ctx, request)
}
func (repo *AuthRequestRepo) SetLinkingUser(ctx context.Context, request *domain.AuthRequest, externalUser *domain.ExternalUser) error {
for i, user := range request.LinkingUsers {
if user.ExternalUserID == externalUser.ExternalUserID {
request.LinkingUsers[i] = externalUser
return repo.AuthRequests.UpdateAuthRequest(ctx, request)
}
}
return nil
}
func (repo *AuthRequestRepo) setLinkingUser(ctx context.Context, request *domain.AuthRequest, externalUser *domain.ExternalUser) error {
request.LinkingUsers = append(request.LinkingUsers, externalUser)
return repo.AuthRequests.UpdateAuthRequest(ctx, request)

View File

@ -16,7 +16,7 @@ import (
// 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) {
func (c *Commands) RegisterUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment) (*domain.WebAuthNRegistrationDetails, error) {
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
}
@ -25,7 +25,7 @@ func (c *Commands) RegisterUserPasskey(ctx context.Context, userID, resourceOwne
// 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) {
func (c *Commands) RegisterUserPasskeyWithCode(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment, codeID, code string, alg crypto.EncryptionAlgorithm) (*domain.WebAuthNRegistrationDetails, error) {
event, err := c.verifyUserPasskeyCode(ctx, userID, resourceOwner, codeID, code, alg)
if err != nil {
return nil, err
@ -63,7 +63,7 @@ func (c *Commands) verifyUserPasskeyCodeFailed(ctx context.Context, wm *HumanPas
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) {
func (c *Commands) registerUserPasskey(ctx context.Context, userID, resourceOwner string, authenticator domain.AuthenticatorAttachment, events ...eventCallback) (*domain.WebAuthNRegistrationDetails, error) {
wm, userAgg, webAuthN, err := c.createUserPasskey(ctx, userID, resourceOwner, authenticator)
if err != nil {
return nil, err
@ -79,7 +79,7 @@ func (c *Commands) createUserPasskey(ctx context.Context, userID, resourceOwner
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) {
func (c *Commands) pushUserPasskey(ctx context.Context, wm *HumanWebAuthNWriteModel, userAgg *eventstore.Aggregate, webAuthN *domain.WebAuthNToken, events ...eventCallback) (*domain.WebAuthNRegistrationDetails, error) {
cmds := make([]eventstore.Command, len(events)+1)
cmds[0] = user.NewHumanPasswordlessAddedEvent(ctx, userAgg, wm.WebauthNTokenID, webAuthN.Challenge)
for i, event := range events {
@ -90,9 +90,9 @@ func (c *Commands) pushUserPasskey(ctx context.Context, wm *HumanWebAuthNWriteMo
if err != nil {
return nil, err
}
return &domain.PasskeyRegistrationDetails{
return &domain.WebAuthNRegistrationDetails{
ObjectDetails: writeModelToObjectDetails(&wm.WriteModel),
PasskeyID: wm.WebauthNTokenID,
ID: wm.WebauthNTokenID,
PublicKeyCredentialCreationOptions: webAuthN.CredentialCreationData,
}, nil
}

View File

@ -46,7 +46,7 @@ func TestCommands_RegisterUserPasskey(t *testing.T) {
name string
fields fields
args args
want *domain.PasskeyRegistrationDetails
want *domain.WebAuthNRegistrationDetails
wantErr error
}{
{
@ -449,7 +449,7 @@ func TestCommands_pushUserPasskey(t *testing.T) {
require.ErrorIs(t, err, tt.wantErr)
if tt.wantErr == nil {
assert.NotEmpty(t, got.PublicKeyCredentialCreationOptions)
assert.Equal(t, "123", got.PasskeyID)
assert.Equal(t, "123", got.ID)
assert.Equal(t, "org1", got.ObjectDetails.ResourceOwner)
}
})

View File

@ -0,0 +1,46 @@
package command
import (
"context"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/user"
)
func (c *Commands) RegisterUserU2F(ctx context.Context, userID, resourceOwner string) (*domain.WebAuthNRegistrationDetails, error) {
if err := authz.UserIDInCTX(ctx, userID); err != nil {
return nil, err
}
return c.registerUserU2F(ctx, userID, resourceOwner)
}
func (c *Commands) registerUserU2F(ctx context.Context, userID, resourceOwner string) (*domain.WebAuthNRegistrationDetails, error) {
wm, userAgg, webAuthN, err := c.createUserU2F(ctx, userID, resourceOwner)
if err != nil {
return nil, err
}
return c.pushUserU2F(ctx, wm, userAgg, webAuthN)
}
func (c *Commands) createUserU2F(ctx context.Context, userID, resourceOwner string) (*HumanWebAuthNWriteModel, *eventstore.Aggregate, *domain.WebAuthNToken, error) {
tokens, err := c.getHumanU2FTokens(ctx, userID, resourceOwner)
if err != nil {
return nil, nil, nil, err
}
return c.addHumanWebAuthN(ctx, userID, resourceOwner, false, tokens, domain.AuthenticatorAttachmentUnspecified, domain.UserVerificationRequirementRequired)
}
func (c *Commands) pushUserU2F(ctx context.Context, wm *HumanWebAuthNWriteModel, userAgg *eventstore.Aggregate, webAuthN *domain.WebAuthNToken) (*domain.WebAuthNRegistrationDetails, error) {
cmd := user.NewHumanU2FAddedEvent(ctx, userAgg, wm.WebauthNTokenID, webAuthN.Challenge)
err := c.pushAppendAndReduce(ctx, wm, cmd)
if err != nil {
return nil, err
}
return &domain.WebAuthNRegistrationDetails{
ObjectDetails: writeModelToObjectDetails(&wm.WriteModel),
ID: wm.WebauthNTokenID,
PublicKeyCredentialCreationOptions: webAuthN.CredentialCreationData,
}, nil
}

View File

@ -0,0 +1,215 @@
package command
import (
"context"
"io"
"testing"
"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/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_RegisterUserU2F(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
}
tests := []struct {
name string
fields fields
args args
want *domain.WebAuthNRegistrationDetails
wantErr error
}{
{
name: "wrong user",
args: args{
userID: "foo",
resourceOwner: "org1",
},
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",
},
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",
},
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.RegisterUserU2F(ctx, tt.args.userID, tt.args.resourceOwner)
require.ErrorIs(t, err, tt.wantErr)
// successful case can't be tested due to random challenge.
})
}
}
func TestCommands_pushUserU2F(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(), // getHumanU2FTokens
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"),
)),
}
tests := []struct {
name string
expectPush func(challenge string) expect
wantErr error
}{
{
name: "push error",
expectPush: func(challenge string) expect {
return expectPushFailed(io.ErrClosedPipe, []*repository.Event{eventFromEventPusher(
user.NewHumanU2FAddedEvent(ctx,
userAgg, "123", challenge,
),
)})
},
wantErr: io.ErrClosedPipe,
},
{
name: "success",
expectPush: func(challenge string) expect {
return expectPush([]*repository.Event{eventFromEventPusher(
user.NewHumanU2FAddedEvent(ctx,
userAgg, "123", challenge,
),
)})
},
},
}
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.pushUserU2F(ctx, wm, userAgg, webAuthN)
require.ErrorIs(t, err, tt.wantErr)
if tt.wantErr == nil {
assert.NotEmpty(t, got.PublicKeyCredentialCreationOptions)
assert.Equal(t, "123", got.ID)
assert.Equal(t, "org1", got.ObjectDetails.ResourceOwner)
}
})
}
}

View File

@ -42,7 +42,7 @@ type AuthRequest struct {
PrivateLabelingSetting PrivateLabelingSetting
SelectedIDPConfigID string
LinkingUsers []*ExternalUser
PossibleSteps []NextStep
PossibleSteps []NextStep `json:"-"`
PasswordVerified bool
MFAsVerified []MFAType
Audience []string

View File

@ -21,9 +21,9 @@ type PasskeyCodeDetails struct {
Code string
}
type PasskeyRegistrationDetails struct {
type WebAuthNRegistrationDetails struct {
*ObjectDetails
PasskeyID string
ID string
PublicKeyCredentialCreationOptions []byte
}