From 022bf7406081b3a4efb9b2964f5b06db0e7c532c Mon Sep 17 00:00:00 2001 From: "Marco A." Date: Fri, 27 Mar 2026 12:38:40 +0100 Subject: [PATCH] feat(session): Passkey Check API with relation tables (#11858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved As part of #11035 , this PR implements the Passkey check logic for session validation # How the Problems Are Solved - Refactor webauth FinishLogin to support new domain model - Add webauth config to defaults - Implement passkey check logic and tests - Manual transaction management to avoid stalling the DB while FinishLogin callback is executed - Update passkey Type condition to allow passing a text operation (equal, contains, etc..) # Additional Context This is a cherry-picked PR + minor changes, coming from https://github.com/zitadel/zitadel/pull/11164 - Relates to #11035 --------- Co-authored-by: Fabienne Bühler Co-authored-by: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> --- .../v2/convert/session_check_passkey.go | 15 + .../v2/convert/session_check_passkey_test.go | 78 ++ backend/v3/domain/defaults.go | 6 + backend/v3/domain/errors.go | 20 + backend/v3/domain/errors_test.go | 72 ++ backend/v3/domain/invoke_option.go | 29 + backend/v3/domain/invoke_option_test.go | 146 ++++ backend/v3/domain/mock/user_repo.go | 4 + backend/v3/domain/passkey.go | 81 ++ backend/v3/domain/passkey_test.go | 158 ++++ backend/v3/domain/session_check_passkey.go | 226 ++++++ .../v3/domain/session_check_passkey_test.go | 716 ++++++++++++++++++ backend/v3/domain/session_check_password.go | 6 +- backend/v3/domain/user.go | 1 - backend/v3/domain/user_repo.go | 2 +- .../database/repository/user_passkey.go | 4 +- .../storage/database/repository/user_test.go | 2 +- cmd/start/start.go | 3 + internal/command/session_webauhtn.go | 2 +- internal/command/user_human_webauthn.go | 2 +- internal/domain/human_web_auth_n.go | 10 +- internal/query/projection/user_relational.go | 4 +- internal/webauthn/webauthn.go | 24 + 23 files changed, 1591 insertions(+), 20 deletions(-) create mode 100644 backend/v3/api/session/v2/convert/session_check_passkey.go create mode 100644 backend/v3/api/session/v2/convert/session_check_passkey_test.go create mode 100644 backend/v3/domain/invoke_option_test.go create mode 100644 backend/v3/domain/passkey.go create mode 100644 backend/v3/domain/passkey_test.go create mode 100644 backend/v3/domain/session_check_passkey.go create mode 100644 backend/v3/domain/session_check_passkey_test.go diff --git a/backend/v3/api/session/v2/convert/session_check_passkey.go b/backend/v3/api/session/v2/convert/session_check_passkey.go new file mode 100644 index 000000000..c42b87f7e --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_check_passkey.go @@ -0,0 +1,15 @@ +package convert + +import ( + "encoding/json" + + session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func CheckPasskeyGRPCToDomain(checkPasskey *session_grpc.CheckWebAuthN) ([]byte, error) { + if checkPasskey == nil || checkPasskey.GetCredentialAssertionData() == nil { + return nil, nil + } + + return json.Marshal(checkPasskey.GetCredentialAssertionData()) +} diff --git a/backend/v3/api/session/v2/convert/session_check_passkey_test.go b/backend/v3/api/session/v2/convert/session_check_passkey_test.go new file mode 100644 index 000000000..6954ef75f --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_check_passkey_test.go @@ -0,0 +1,78 @@ +package convert + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/structpb" + + session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2" +) + +func TestCheckPasskeyGRPCToDomain(t *testing.T) { + t.Parallel() + tt := []struct { + testName string + input *session_grpc.CheckWebAuthN + expectedBytes []byte + expectedError error + }{ + { + testName: "when input is nil should return nil bytes and nil error", + input: nil, + expectedBytes: nil, + expectedError: nil, + }, + { + testName: "when credential assertion data is nil should return nil bytes and nil error", + input: &session_grpc.CheckWebAuthN{ + CredentialAssertionData: nil, + }, + expectedBytes: nil, + expectedError: nil, + }, + { + testName: "when credential assertion data is empty should return empty JSON bytes", + input: &session_grpc.CheckWebAuthN{ + CredentialAssertionData: &structpb.Struct{}, + }, + expectedBytes: []byte("{}"), + expectedError: nil, + }, + { + testName: "when credential assertion data is populated should return marshaled JSON bytes", + input: &session_grpc.CheckWebAuthN{ + CredentialAssertionData: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "publicKeyCredentialRequestOptions": { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "challenge": structpb.NewStringValue("Y2hhbGxlbmdl"), + "rpId": structpb.NewStringValue("example.com"), + "timeout": structpb.NewNumberValue(5000), + }, + }, + }, + }, + }, + }, + }, + expectedBytes: []byte(`{"publicKeyCredentialRequestOptions":{"challenge":"Y2hhbGxlbmdl","rpId":"example.com","timeout":5000}}`), + expectedError: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Test + result, err := CheckPasskeyGRPCToDomain(tc.input) + + // Verify + assert.Equal(t, tc.expectedBytes, result) + assert.Equal(t, tc.expectedError, err) + }) + } +} diff --git a/backend/v3/domain/defaults.go b/backend/v3/domain/defaults.go index a40568e53..33920ee3d 100644 --- a/backend/v3/domain/defaults.go +++ b/backend/v3/domain/defaults.go @@ -5,6 +5,7 @@ import ( "github.com/zitadel/zitadel/backend/v3/storage/eventstore" "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/webauthn" ) var ( @@ -16,6 +17,7 @@ var ( sessionTokenDecryptor SessionTokenDecryptor mfaEncryptionAlgo crypto.EncryptionAlgorithm otpSMSSecretGeneratorConfig *crypto.GeneratorConfig + webauthnConfig *webauthn.Config ) func SetPool(p database.Pool) { @@ -49,3 +51,7 @@ func SetOTPSMSSecretGeneratorConfig(cfg *crypto.GeneratorConfig) { func SetMFAEncryptionAlgorithm(mfaEncryptionAlg crypto.EncryptionAlgorithm) { mfaEncryptionAlgo = mfaEncryptionAlg } + +func SetWebAuthNConfig(cfg *webauthn.Config) { + webauthnConfig = cfg +} diff --git a/backend/v3/domain/errors.go b/backend/v3/domain/errors.go index 6191422d8..a74551f37 100644 --- a/backend/v3/domain/errors.go +++ b/backend/v3/domain/errors.go @@ -112,6 +112,26 @@ func handleGetError(inputErr error, errorID, objectType string) error { return zerrors.CreateZitadelError(zerrors.KindInternal, inputErr, errorID, fmt.Sprintf("failed fetching %s", objectType), 1) } +func handleUpdateError(inputErr error, expectedRowCount, actualRowCount int64, errorID, objectType string) error { + if inputErr == nil && expectedRowCount == actualRowCount { + return nil + } + + if inputErr != nil { + return zerrors.CreateZitadelError(zerrors.KindInternal, inputErr, errorID, fmt.Sprintf("failed updating %s", objectType), 1) + } + + if actualRowCount == 0 { + return zerrors.CreateZitadelError(zerrors.KindNotFound, nil, errorID, fmt.Sprintf("%s not found", objectType), 1) + } + + if actualRowCount != expectedRowCount { + return zerrors.CreateZitadelError(zerrors.KindInternal, NewMultipleObjectsUpdatedError(expectedRowCount, actualRowCount), errorID, "unexpected number of rows updated", 1) + } + + return nil +} + func (err *PasswordVerificationError) Is(target error) bool { _, ok := target.(*PasswordVerificationError) return ok diff --git a/backend/v3/domain/errors_test.go b/backend/v3/domain/errors_test.go index e0bbe617e..1df1a0c7f 100644 --- a/backend/v3/domain/errors_test.go +++ b/backend/v3/domain/errors_test.go @@ -1,9 +1,12 @@ package domain import ( + "errors" "testing" "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/zerrors" ) func TestUnexpectedQueryTypeError_Error(t *testing.T) { @@ -36,3 +39,72 @@ func TestUnexpectedQueryTypeError_Error(t *testing.T) { }) } } + +func TestHandleUpdateError(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + inputErr error + expectedRowCount int64 + actualRowCount int64 + errorID string + objectType string + expectedErr error + }{ + { + name: "no error and counts match", + inputErr: nil, + expectedRowCount: 1, + actualRowCount: 1, + errorID: "test-001", + objectType: "user", + expectedErr: nil, + }, + { + name: "input error provided", + inputErr: errors.New("db error"), + expectedRowCount: 1, + actualRowCount: 1, + errorID: "test-002", + objectType: "session", + expectedErr: zerrors.ThrowInternalf(errors.New("db error"), "test-002", "failed updating %s", "session"), + }, + { + name: "no rows affected", + inputErr: nil, + expectedRowCount: 1, + actualRowCount: 0, + errorID: "test-003", + objectType: "idp", + expectedErr: zerrors.ThrowNotFoundf(nil, "test-003", "%s not found", "idp"), + }, + { + name: "unexpected number of rows updated", + inputErr: nil, + expectedRowCount: 1, + actualRowCount: 5, + errorID: "test-004", + objectType: "org", + expectedErr: zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(1, 5), "test-004", "unexpected number of rows updated"), + }, + { + name: "counts mismatch", + inputErr: nil, + expectedRowCount: 2, + actualRowCount: 1, + errorID: "test-005", + objectType: "project", + expectedErr: zerrors.ThrowInternal(NewMultipleObjectsUpdatedError(2, 1), "test-005", "unexpected number of rows updated"), + }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := handleUpdateError(tc.inputErr, tc.expectedRowCount, tc.actualRowCount, tc.errorID, tc.objectType) + assert.Equal(t, tc.expectedErr, err) + }) + } +} diff --git a/backend/v3/domain/invoke_option.go b/backend/v3/domain/invoke_option.go index f57874438..a16d8f7f0 100644 --- a/backend/v3/domain/invoke_option.go +++ b/backend/v3/domain/invoke_option.go @@ -5,6 +5,7 @@ import ( "github.com/zitadel/zitadel/backend/v3/storage/database" "github.com/zitadel/zitadel/backend/v3/storage/eventstore" + "github.com/zitadel/zitadel/internal/zerrors" ) type InvokeOpt func(*InvokeOpts) @@ -136,6 +137,34 @@ func (o *InvokeOpts) DB() database.QueryExecutor { return o.db } +// StartTransactionFromDB returns a [database.Transaction] from the input [database.QueryExecutor]. +// Optionally, the caller can pass [database.TransactionOptions] for a customised transaction type. +// +// If db doesn't implement [database.Beginner] an internal error is returned. +// +// If the transaction [database.Beginner.Begin] call fails, an internal error is returned. +// +// The caller is in charge of calling [database.Transaction.End], [database.Transaction.Commit] +// or [database.Transaction.Rollback] as they see fit. +func (o *InvokeOpts) StartTransactionFromDB(ctx context.Context, db database.QueryExecutor, opts *database.TransactionOptions) (database.Transaction, error) { + beginner, ok := db.(database.Beginner) + if !ok { + return nil, zerrors.CreateZitadelError(zerrors.KindInternal, nil, "DOM-LqxZbk", "database doesn't implement database.Beginner", 1) + } + + tx, txErr := beginner.Begin(ctx, opts) + if txErr != nil { + return nil, zerrors.CreateZitadelError(zerrors.KindInternal, txErr, "DOM-sAAd3V", "failed starting transaction", 1) + } + + return tx, nil +} + +// StartTransaction is the same as [domain.StartTransactionFromDB] but uses the DB provided by [InvokeOpts] +func (o *InvokeOpts) StartTransaction(ctx context.Context, opts *database.TransactionOptions) (database.Transaction, error) { + return o.StartTransactionFromDB(ctx, o.DB(), opts) +} + func (o *InvokeOpts) LegacyEventstore() eventstore.LegacyEventstore { if o.legacyEventstore != nil { return o.legacyEventstore diff --git a/backend/v3/domain/invoke_option_test.go b/backend/v3/domain/invoke_option_test.go new file mode 100644 index 000000000..3e74ecdbd --- /dev/null +++ b/backend/v3/domain/invoke_option_test.go @@ -0,0 +1,146 @@ +package domain + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/internal/zerrors" +) + +var _ database.QueryExecutor = (*nonBeginnerDB)(nil) +var _ database.Transaction = (*transactionDB)(nil) +var _ database.Beginner = (*beginnerDB)(nil) +var _ database.QueryExecutor = (*beginnerDB)(nil) + +type beginnerDB struct { + errToReturn error + errOnBegin error +} + +func (n *beginnerDB) Begin(ctx context.Context, opts *database.TransactionOptions) (database.Transaction, error) { + if n.errToReturn != nil { + return nil, n.errToReturn + } + if n.errOnBegin != nil { + return nil, n.errOnBegin + } + return &transactionDB{}, nil +} + +// Exec implements [database.QueryExecutor]. +func (n *beginnerDB) Exec(ctx context.Context, stmt string, args ...any) (int64, error) { + return 0, n.errToReturn +} + +// Query implements [database.QueryExecutor]. +func (n *beginnerDB) Query(ctx context.Context, stmt string, args ...any) (database.Rows, error) { + return nil, n.errToReturn +} + +// QueryRow implements [database.QueryExecutor]. +func (n *beginnerDB) QueryRow(ctx context.Context, stmt string, args ...any) database.Row { + return nil +} + +type transactionDB struct{} + +// Exec implements [database.QueryExecutor]. +func (n *transactionDB) Exec(ctx context.Context, stmt string, args ...any) (int64, error) { + return 0, nil +} + +// Query implements [database.QueryExecutor]. +func (n *transactionDB) Query(ctx context.Context, stmt string, args ...any) (database.Rows, error) { + return nil, nil +} + +// QueryRow implements [database.QueryExecutor]. +func (n *transactionDB) QueryRow(ctx context.Context, stmt string, args ...any) database.Row { + return nil +} + +// Begin implements [database.Transaction]. +func (n *transactionDB) Begin(ctx context.Context) (database.Transaction, error) { + return n, nil +} + +// Commit implements [database.Transaction]. +func (n *transactionDB) Commit(ctx context.Context) error { + return nil +} + +// End implements [database.Transaction]. +func (n *transactionDB) End(ctx context.Context, err error) error { + return nil +} + +// Rollback implements [database.Transaction]. +func (n *transactionDB) Rollback(ctx context.Context) error { + return nil +} + +type nonBeginnerDB struct{} + +// Exec implements [database.QueryExecutor]. +func (n *nonBeginnerDB) Exec(ctx context.Context, stmt string, args ...any) (int64, error) { + return 0, nil +} + +// Query implements [database.QueryExecutor]. +func (n *nonBeginnerDB) Query(ctx context.Context, stmt string, args ...any) (database.Rows, error) { + return nil, nil +} + +// QueryRow implements [database.QueryExecutor]. +func (n *nonBeginnerDB) QueryRow(ctx context.Context, stmt string, args ...any) database.Row { + return nil +} + +func TestStartTransactionFromDB(t *testing.T) { + t.Parallel() + + txErr := errors.New("tx error") + + tt := []struct { + testName string + inputDB database.QueryExecutor + expectedError error + expectedValidTransaction bool + }{ + { + testName: "when input DB doesn't implement database.Beginner should return internal error", + inputDB: &nonBeginnerDB{}, + expectedError: zerrors.CreateZitadelError(zerrors.KindInternal, nil, "DOM-LqxZbk", "database doesn't implement database.Beginner", 1), + }, + { + testName: "when transaction Begin fails should return internal error", + inputDB: &beginnerDB{errOnBegin: txErr}, + expectedError: zerrors.CreateZitadelError(zerrors.KindInternal, txErr, "DOM-sAAd3V", "failed starting transaction", 1), + }, + { + testName: "when transaction Begin succeeds should return transaction", + inputDB: &beginnerDB{}, + expectedValidTransaction: true, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + + // Given + invokeOpts := InvokeOpts{} + + // Test + tx, err := invokeOpts.StartTransactionFromDB(t.Context(), tc.inputDB, nil) + + // Verify + assert.ErrorIs(t, err, tc.expectedError) + assert.Equal(t, tc.expectedValidTransaction, tx != nil) + }) + } +} diff --git a/backend/v3/domain/mock/user_repo.go b/backend/v3/domain/mock/user_repo.go index e96696fb8..2a5f98adf 100644 --- a/backend/v3/domain/mock/user_repo.go +++ b/backend/v3/domain/mock/user_repo.go @@ -38,6 +38,10 @@ func (u *UserRepo) Create(ctx context.Context, client database.QueryExecutor, us return u.mock.Create(ctx, client, user) } +func (u *UserRepo) Update(ctx context.Context, client database.QueryExecutor, condition database.Condition, changes ...database.Change) (int64, error) { + return u.mock.Update(ctx, client, condition, changes...) +} + func (u *UserRepo) Delete(ctx context.Context, client database.QueryExecutor, condition database.Condition) (int64, error) { return u.mock.Delete(ctx, client, condition) } diff --git a/backend/v3/domain/passkey.go b/backend/v3/domain/passkey.go new file mode 100644 index 000000000..4198f9b6b --- /dev/null +++ b/backend/v3/domain/passkey.go @@ -0,0 +1,81 @@ +package domain + +import ( + "context" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/zitadel/zitadel/internal/api/http" + old_domain "github.com/zitadel/zitadel/internal/domain" +) + +func PasskeysToCredentials(ctx context.Context, passkeys []*Passkey, rpID string) []webauthn.Credential { + creds := make([]webauthn.Credential, 0) + + for _, pkey := range passkeys { + if !pkey.VerifiedAt.IsZero() && + (pkey.RelyingPartyID == rpID || + (pkey.RelyingPartyID == "" && rpID == http.DomainContext(ctx).InstanceDomain())) { + creds = append(creds, webauthn.Credential{ + ID: pkey.KeyID, + PublicKey: pkey.PublicKey, + AttestationType: pkey.AttestationType, + Authenticator: webauthn.Authenticator{ + AAGUID: pkey.AuthenticatorAttestationGUID, + SignCount: pkey.SignCount, + }, + }) + + } + } + + return creds +} + +func UserVerificationFromDomain(verification old_domain.UserVerificationRequirement) protocol.UserVerificationRequirement { + switch verification { + case old_domain.UserVerificationRequirementRequired: + return protocol.VerificationRequired + case old_domain.UserVerificationRequirementPreferred: + return protocol.VerificationPreferred + case old_domain.UserVerificationRequirementDiscouraged, old_domain.UserVerificationRequirementUnspecified: + fallthrough + default: + return protocol.VerificationDiscouraged + } +} + +type webAuthNUser struct { + userID string + username string + displayName string + creds []webauthn.Credential +} + +// WebAuthnCredentials implements [webauthn.User]. +func (w *webAuthNUser) WebAuthnCredentials() []webauthn.Credential { + return w.creds +} + +// WebAuthnDisplayName implements [webauthn.User]. +func (w *webAuthNUser) WebAuthnDisplayName() string { + return w.displayName +} + +// WebAuthnID implements [webauthn.User]. +func (w *webAuthNUser) WebAuthnID() []byte { + return []byte(w.userID) +} + +// WebAuthnIcon implements [webauthn.User]. +func (w *webAuthNUser) WebAuthnIcon() string { + return "" +} + +// WebAuthnName implements [webauthn.User]. +func (w *webAuthNUser) WebAuthnName() string { + return w.username +} + +var _ webauthn.User = (*webAuthNUser)(nil) diff --git a/backend/v3/domain/passkey_test.go b/backend/v3/domain/passkey_test.go new file mode 100644 index 000000000..8c89cded6 --- /dev/null +++ b/backend/v3/domain/passkey_test.go @@ -0,0 +1,158 @@ +package domain_test + +import ( + "context" + "testing" + "time" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/backend/v3/domain" + "github.com/zitadel/zitadel/internal/api/http" +) + +func TestPasskeysToCredentials(t *testing.T) { + verificationDate := time.Now() + + t.Parallel() + type args struct { + ctx context.Context + passkeys []*domain.Passkey + rpID string + } + tt := []struct { + name string + args args + want []webauthn.Credential + }{ + { + name: "matching rpID", + args: args{ + ctx: context.Background(), + passkeys: []*domain.Passkey{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AuthenticatorAttestationGUID: []byte("aaguid1"), + SignCount: 1, + RelyingPartyID: "example.com", + VerifiedAt: verificationDate, + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{ + { + ID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + Authenticator: webauthn.Authenticator{ + AAGUID: []byte("aaguid1"), + SignCount: 1, + }, + }, + }, + }, + { + name: "not matching rpID", + args: args{ + ctx: context.Background(), + passkeys: []*domain.Passkey{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AuthenticatorAttestationGUID: []byte("aaguid1"), + SignCount: 1, + RelyingPartyID: "other.com", + VerifiedAt: verificationDate, + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{}, + }, + { + name: "no rpID, same host", + args: args{ + ctx: http.WithDomainContext(context.Background(), &http.DomainCtx{ + InstanceHost: "example.com:443", + PublicHost: "example.com:443", + Protocol: "https", + }), + passkeys: []*domain.Passkey{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AuthenticatorAttestationGUID: []byte("aaguid1"), + SignCount: 1, + RelyingPartyID: "", + VerifiedAt: verificationDate, + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{ + { + ID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + Authenticator: webauthn.Authenticator{ + AAGUID: []byte("aaguid1"), + SignCount: 1, + }, + }, + }, + }, + { + name: "no rpID, different host", + args: args{ + ctx: http.WithDomainContext(context.Background(), &http.DomainCtx{ + InstanceHost: "other.com:443", + PublicHost: "other.com:443", + Protocol: "https", + }), + passkeys: []*domain.Passkey{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AuthenticatorAttestationGUID: []byte("aaguid1"), + SignCount: 1, + RelyingPartyID: "", + VerifiedAt: verificationDate, + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{}, + }, + { + name: "pkey not verified should return empty", + args: args{ + ctx: context.Background(), + passkeys: []*domain.Passkey{ + { + KeyID: []byte("key1"), + PublicKey: []byte("publicKey1"), + AttestationType: "attestation1", + AuthenticatorAttestationGUID: []byte("aaguid1"), + SignCount: 1, + RelyingPartyID: "example.com", + }, + }, + rpID: "example.com", + }, + want: []webauthn.Credential{}, + }, + } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equalf(t, tc.want, domain.PasskeysToCredentials(tc.args.ctx, tc.args.passkeys, tc.args.rpID), "PasskeysToCredentials(%v, %v, %v)", tc.args.ctx, tc.args.passkeys, tc.args.rpID) + }) + } +} diff --git a/backend/v3/domain/session_check_passkey.go b/backend/v3/domain/session_check_passkey.go new file mode 100644 index 000000000..df491ccf1 --- /dev/null +++ b/backend/v3/domain/session_check_passkey.go @@ -0,0 +1,226 @@ +package domain + +import ( + "bytes" + "context" + "time" + + "github.com/go-webauthn/webauthn/webauthn" + + "github.com/zitadel/zitadel/backend/v3/storage/database" + old_domain "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type FinishLoginFunc func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) + +type PasskeyCheckCommand struct { + // CheckPasskey is the assertion data for the passkey + CheckPasskey []byte + + FinishLoginFn FinishLoginFunc + + SessionID string + InstanceID string + + FetchedUser *User + FetchedSession *Session + + // For Events() + LastVerifiedAt time.Time + UserVerified bool + PKeyID string + PKeySignCount uint32 +} + +// NewPasskeyCheckCommand initializes a new [PasskeyCheckCommand] +// +// If finishLoginFn is nil, [webauthnConfig.FinishLoginWithNewDomainModel] will be used. +// If that is nil as well, an error will be returned. +// +// assertionData is passkey assertion required for validation +// +// The command does not implement [Transactional] due finishLoginFn that might take a long time to execute. +// So the DB transaction will be started only after finishLoginFn has been run. +func NewPasskeyCheckCommand(sessionID, instanceID string, assertionData []byte, finishLoginFn FinishLoginFunc) (*PasskeyCheckCommand, error) { + if webauthnConfig == nil && finishLoginFn == nil { + return nil, zerrors.ThrowInternal(nil, "DOM-bhzmHO", "no finish login function set") + } + + pcc := &PasskeyCheckCommand{ + CheckPasskey: assertionData, + SessionID: sessionID, + InstanceID: instanceID, + } + + if finishLoginFn != nil { + pcc.FinishLoginFn = finishLoginFn + } else { + pcc.FinishLoginFn = webauthnConfig.FinishLoginWithNewDomainModel + } + + return pcc, nil +} + +// Events implements [Commander]. +func (p *PasskeyCheckCommand) Events(ctx context.Context, opts *InvokeOpts) ([]eventstore.Command, error) { + if p.CheckPasskey == nil { + return nil, nil + } + + passkeyChallenge := p.FetchedSession.Challenges.GetPasskeyChallenge() + + events := make([]eventstore.Command, 2) + + sessionAgg := &session.NewAggregate(p.SessionID, p.InstanceID).Aggregate + events[0] = session.NewWebAuthNCheckedEvent(ctx, sessionAgg, p.LastVerifiedAt, p.UserVerified) + if passkeyChallenge.UserVerification == old_domain.UserVerificationRequirementRequired { + events[1] = user.NewHumanPasswordlessSignCountChangedEvent(ctx, sessionAgg, p.PKeyID, p.PKeySignCount) + } else { + events[1] = user.NewHumanU2FSignCountChangedEvent(ctx, sessionAgg, p.PKeyID, p.PKeySignCount) + } + + return events, nil +} + +// Execute implements [Commander]. +func (p *PasskeyCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) { + if p.CheckPasskey == nil { + return nil + } + + sessionRepo := opts.sessionRepo + userRepo := opts.userRepo + + passkeyChallenge := p.FetchedSession.Challenges.GetPasskeyChallenge() + + userPKeys := p.FetchedUser.Human.Passkeys + + webAuthnUsr := &webAuthNUser{ + userID: p.FetchedUser.ID, + username: p.FetchedUser.Username, + displayName: p.FetchedUser.Human.DisplayName, + creds: PasskeysToCredentials(ctx, userPKeys, passkeyChallenge.RPID), + } + + webAuthCreds, err := p.FinishLoginFn(ctx, p.getWebAuthNSessionData(passkeyChallenge, p.FetchedUser.ID), webAuthnUsr, p.CheckPasskey, passkeyChallenge.RPID) + if err != nil && (webAuthCreds == nil || webAuthCreds.ID == nil) { + return err + } + + var matchingPKey *Passkey + for _, pkey := range userPKeys { + if bytes.Equal(pkey.KeyID, webAuthCreds.ID) { + matchingPKey = pkey + break + } + } + if matchingPKey == nil { + return zerrors.ThrowPreconditionFailed(nil, "DOM-uuxodH", "Errors.User.WebAuthN.NotFound") + } + p.PKeyID = matchingPKey.ID + + tx, txErr := opts.StartTransaction(ctx, nil) + if txErr != nil { + return zerrors.ThrowInternal(txErr, "DOM-sAAd3V", "failed starting transaction") + } + + defer func() { + if endErr := tx.End(ctx, txErr); endErr != nil { + err = endErr + } + }() + + p.UserVerified = webAuthCreds.Flags.UserVerified + p.LastVerifiedAt = time.Now() + rowCount, err := sessionRepo.Update(ctx, tx, + sessionRepo.IDCondition(p.SessionID), + sessionRepo.SetFactor(&SessionFactorPasskey{LastVerifiedAt: p.LastVerifiedAt, UserVerified: p.UserVerified}), + ) + if err := handleUpdateError(err, 1, rowCount, "DOM-Uadvap", "session"); err != nil { + txErr = err + return err + } + + rowCount, err = userRepo.Update(ctx, tx, + database.And( + userRepo.Human().PrimaryKeyCondition(p.InstanceID, p.FetchedUser.ID), + userRepo.Human().PasskeyConditions().IDCondition(matchingPKey.ID), + ), + userRepo.Human().SetPasskeySignCount(webAuthCreds.Authenticator.SignCount), + ) + if err := handleUpdateError(err, 1, rowCount, "DOM-wdwZYk", "user"); err != nil { + txErr = err + return err + } + p.PKeySignCount = webAuthCreds.Authenticator.SignCount + + return nil +} + +// String implements [Commander]. +func (p *PasskeyCheckCommand) String() string { + return "PasskeyCheckCommand" +} + +// Validate implements [Commander]. +func (p *PasskeyCheckCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) { + if p.CheckPasskey == nil { + return nil + } + + if p.SessionID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-4QJa2k", "Errors.Missing.SessionID") + } + if p.InstanceID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-XlOhxU", "Errors.Missing.InstanceID") + } + + sessionRepo := opts.sessionRepo + userRepo := opts.userRepo + + p.FetchedSession, err = sessionRepo.Get(ctx, opts.DB(), database.WithCondition(sessionRepo.IDCondition(p.SessionID))) + if err := handleGetError(err, "DOM-CUnePh", "session"); err != nil { + return err + } + + passkeyChallenge := p.FetchedSession.Challenges.GetPasskeyChallenge() + if passkeyChallenge == nil { + return zerrors.ThrowPreconditionFailed(nil, "DOM-lQhNR4", "Errors.Session.WebAuthN.NoChallenge") + } + + if p.FetchedSession.UserID == "" { + return zerrors.ThrowPreconditionFailed(nil, "DOM-jy0zq7", "Errors.User.UserIDMissing") + } + + var passKeyCondition database.Condition + if passkeyChallenge.UserVerification == old_domain.UserVerificationRequirementRequired { + passKeyCondition = userRepo.Human().PasskeyConditions().TypeCondition(database.TextOperationEqual, PasskeyTypePasswordless) + } else { + passKeyCondition = userRepo.Human().PasskeyConditions().TypeCondition(database.TextOperationEqual, PasskeyTypeU2F) + } + + p.FetchedUser, err = userRepo.Get(ctx, opts.DB(), + database.WithCondition(userRepo.IDCondition(p.FetchedSession.UserID)), + database.WithCondition(passKeyCondition), + ) + if err := handleGetError(err, "DOM-pB6Mlm", "user"); err != nil { + return err + } + + return nil +} + +func (p *PasskeyCheckCommand) getWebAuthNSessionData(sessionChallenge *SessionChallengePasskey, userID string) webauthn.SessionData { + return webauthn.SessionData{ + Challenge: sessionChallenge.Challenge, + UserID: []byte(userID), + AllowedCredentialIDs: sessionChallenge.AllowedCredentialIDs, + UserVerification: UserVerificationFromDomain(sessionChallenge.UserVerification), + } +} + +var _ Commander = (*PasskeyCheckCommand)(nil) diff --git a/backend/v3/domain/session_check_passkey_test.go b/backend/v3/domain/session_check_passkey_test.go new file mode 100644 index 000000000..762be621f --- /dev/null +++ b/backend/v3/domain/session_check_passkey_test.go @@ -0,0 +1,716 @@ +package domain_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/go-webauthn/webauthn/webauthn" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/zitadel/zitadel/backend/v3/domain" + domainmock "github.com/zitadel/zitadel/backend/v3/domain/mock" + "github.com/zitadel/zitadel/backend/v3/storage/database" + "github.com/zitadel/zitadel/backend/v3/storage/database/dbmock" + noopdb "github.com/zitadel/zitadel/backend/v3/storage/database/dialect/noop" + "github.com/zitadel/zitadel/internal/api/authz" + old_domain "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/session" + "github.com/zitadel/zitadel/internal/repository/user" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func TestPasskeyCheckCommand_Validate(t *testing.T) { + t.Parallel() + getErr := errors.New("get error") + + tt := []struct { + testName string + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + userRepo func(ctrl *gomock.Controller) domain.UserRepository + cmd *domain.PasskeyCheckCommand + expectedError error + }{ + { + testName: "when checkPasskey is nil should return no error", + cmd: &domain.PasskeyCheckCommand{}, + expectedError: nil, + }, + { + testName: "when sessionID is not set should return error", + cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}}, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-4QJa2k", "Errors.Missing.SessionID"), + }, + { + testName: "when instanceID is not set should return error", + cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1"}, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-XlOhxU", "Errors.Missing.InstanceID"), + }, + { + testName: "when retrieving session fails should return error", + cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.IDCondition("session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + idCondition, + ), + )). + Times(1). + Return(nil, getErr) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + return repo + }, + expectedError: zerrors.ThrowInternal(getErr, "DOM-CUnePh", "failed fetching session"), + }, + { + testName: "when session has no passkey challenge should return precondition failed error", + cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.IDCondition("session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + idCondition, + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + Challenges: domain.SessionChallenges{}, + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + return repo + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-lQhNR4", "Errors.Session.WebAuthN.NoChallenge"), + }, + { + testName: "when session has no user ID should return precondition failed error", + cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + + idCondition := repo.IDCondition("session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + idCondition, + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + UserID: "", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{ + LastChallengedAt: time.Time{}, + Challenge: "challenge", + AllowedCredentialIDs: [][]byte{}, + UserVerification: 0, + RPID: "example.com", + }, + }, + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + return repo + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-jy0zq7", "Errors.User.UserIDMissing"), + }, + { + testName: "when retrieving user fails should return error", + cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.IDCondition("session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + idCondition, + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{ + Challenge: "challenge", + RPID: "example.com", + UserVerification: old_domain.UserVerificationRequirementRequired, + }, + }, + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + humanRepo := domainmock.NewHumanRepo(ctrl) + repo.EXPECT().Human().Times(1).Return(humanRepo) + + idCondition := repo.IDCondition("user-1") + + pkeyCondition := humanRepo. + PasskeyConditions(). + TypeCondition(database.TextOperationEqual, domain.PasskeyTypePasswordless) + + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition( + idCondition, + )), + dbmock.QueryOptions(database.WithCondition( + pkeyCondition)), + ). + Times(1). + Return(nil, getErr) + return repo + }, + expectedError: zerrors.ThrowInternal(getErr, "DOM-pB6Mlm", "failed fetching user"), + }, + { + testName: "when all validations pass should return no error", + cmd: &domain.PasskeyCheckCommand{CheckPasskey: []byte{}, SessionID: "session-1", InstanceID: "instance-1"}, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.IDCondition("session-1") + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions( + database.WithCondition( + idCondition, + ), + )). + Times(1). + Return(&domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{ + Challenge: "challenge", + RPID: "example.com", + }, + }, + }, nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + humanRepo := domainmock.NewHumanRepo(ctrl) + + repo.EXPECT().Human().Times(1).Return(humanRepo) + idCondition := repo.IDCondition("user-1") + + pkeyCondition := humanRepo. + PasskeyConditions(). + TypeCondition(database.TextOperationEqual, domain.PasskeyTypeU2F) + + repo.EXPECT(). + Get(gomock.Any(), gomock.Any(), + dbmock.QueryOptions(database.WithCondition( + idCondition, + )), + dbmock.QueryOptions(database.WithCondition( + pkeyCondition)), + ). + Times(1).Return(&domain.User{ + ID: "user-1", + Username: "testuser", + Human: &domain.HumanUser{ + DisplayName: "Test User", + Passkeys: []*domain.Passkey{}, + }, + }, nil) + return repo + }, + expectedError: nil, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "") + ctrl := gomock.NewController(t) + + opts := &domain.InvokeOpts{} + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tc.sessionRepo != nil { + domain.WithSessionRepo(tc.sessionRepo(ctrl))(opts) + } + if tc.userRepo != nil { + domain.WithUserRepo(tc.userRepo(ctrl))(opts) + } + + // Test + err := tc.cmd.Validate(ctx, opts) + + // Verify + assert.ErrorIs(t, err, tc.expectedError) + }) + } +} + +func TestPasskeyCheckCommand_Execute(t *testing.T) { + t.Parallel() + finishLoginErr := errors.New("finish login error") + sessionUpdateErr := errors.New("session update error") + userUpdateErr := errors.New("user update error") + + finishLoginErrFn := func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) { + return nil, finishLoginErr + } + finishLoginNonMatchingPasskeyFn := func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) { + return &webauthn.Credential{ID: []byte("non-matching-key-id")}, nil + } + finishLoginOKFn := func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) { + return &webauthn.Credential{ + ID: []byte("key-id-1"), + Flags: webauthn.CredentialFlags{ + UserVerified: true, + }, + Authenticator: webauthn.Authenticator{ + SignCount: 5, + }, + }, nil + } + + tt := []struct { + testName string + sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository + userRepo func(ctrl *gomock.Controller) domain.UserRepository + finishLoginFn func(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) + cmd *domain.PasskeyCheckCommand + + expectedError error + expectedPasskeySignCount uint32 + expectedPasskeyID string + expectedUserVerified bool + }{ + { + testName: "when checkPasskey is nil should return no error", + cmd: &domain.PasskeyCheckCommand{}, + expectedError: nil, + }, + { + testName: "when finish login fails should return error", + cmd: &domain.PasskeyCheckCommand{ + CheckPasskey: []byte{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: &domain.User{ + ID: "user-1", + Username: "testuser", + Human: &domain.HumanUser{DisplayName: "Test User", Passkeys: []*domain.Passkey{}}, + }, + FetchedSession: &domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"}, + }, + }, + FinishLoginFn: finishLoginErrFn, + }, + expectedError: finishLoginErr, + }, + { + testName: "when passkey not found should return precondition failed error", + cmd: &domain.PasskeyCheckCommand{ + CheckPasskey: []byte{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: &domain.User{ + ID: "user-1", + Username: "testuser", + Human: &domain.HumanUser{ + DisplayName: "Test User", + Passkeys: []*domain.Passkey{ + {ID: "pkey-1", KeyID: []byte("key-id-1")}, + }, + }, + }, + FetchedSession: &domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"}, + }, + }, + FinishLoginFn: finishLoginNonMatchingPasskeyFn, + }, + expectedError: zerrors.ThrowPreconditionFailed(nil, "DOM-uuxodH", "Errors.User.WebAuthN.NotFound"), + }, + { + testName: "when session update fails should return error", + cmd: &domain.PasskeyCheckCommand{ + CheckPasskey: []byte{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: &domain.User{ + ID: "user-1", + Username: "testuser", + Human: &domain.HumanUser{ + DisplayName: "Test User", + Passkeys: []*domain.Passkey{ + {ID: "pkey-1", KeyID: []byte("key-id-1")}, + }, + }, + }, + FetchedSession: &domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"}, + }, + }, + FinishLoginFn: finishLoginOKFn, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.IDCondition("session-1") + factorChange := repo.SetFactor(&domain.SessionFactorPasskey{LastVerifiedAt: time.Now(), UserVerified: true}) + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + idCondition, + factorChange). + Times(1). + Return(int64(0), sessionUpdateErr) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + return repo + }, + expectedError: zerrors.ThrowInternal(sessionUpdateErr, "DOM-Uadvap", "failed updating session"), + expectedPasskeyID: "pkey-1", + expectedUserVerified: true, + }, + { + testName: "when session not found should return not found error", + cmd: &domain.PasskeyCheckCommand{ + CheckPasskey: []byte{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: &domain.User{ + ID: "user-1", + Username: "testuser", + Human: &domain.HumanUser{ + DisplayName: "Test User", + Passkeys: []*domain.Passkey{ + {ID: "pkey-1", KeyID: []byte("key-id-1")}, + }, + }, + }, + FetchedSession: &domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"}, + }, + }, + FinishLoginFn: finishLoginOKFn, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.IDCondition("session-1") + factorChange := repo.SetFactor(&domain.SessionFactorPasskey{LastVerifiedAt: time.Now(), UserVerified: true}) + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + idCondition, + factorChange). + Times(1). + Return(int64(0), nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + return repo + }, + expectedError: zerrors.ThrowNotFound(nil, "DOM-Uadvap", "session not found"), + expectedPasskeyID: "pkey-1", + expectedUserVerified: true, + }, + { + testName: "when user update fails should return error", + cmd: &domain.PasskeyCheckCommand{ + CheckPasskey: []byte{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: &domain.User{ + ID: "user-1", + Username: "testuser", + Human: &domain.HumanUser{ + DisplayName: "Test User", + Passkeys: []*domain.Passkey{ + {ID: "pkey-1", KeyID: []byte("key-id-1")}, + }, + }, + }, + FetchedSession: &domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"}, + }, + }, + FinishLoginFn: finishLoginOKFn, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.IDCondition("session-1") + factorChange := repo.SetFactor(&domain.SessionFactorPasskey{LastVerifiedAt: time.Now(), UserVerified: true}) + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + idCondition, + factorChange). + Times(1). + Return(int64(1), nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + humanRepo := domainmock.NewHumanRepo(ctrl) + repo.EXPECT().Human().Times(3).Return(humanRepo) + updateConditions := database.And( + humanRepo.PrimaryKeyCondition("instance-1", "user-1"), + humanRepo.PasskeyConditions().IDCondition("pkey-1"), + ) + pkeySignCountChange := humanRepo.SetPasskeySignCount(5) + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + updateConditions, + pkeySignCountChange). + Times(1). + Return(int64(0), userUpdateErr) + return repo + }, + expectedError: zerrors.ThrowInternal(userUpdateErr, "DOM-wdwZYk", "failed updating user"), + expectedPasskeyID: "pkey-1", + expectedUserVerified: true, + }, + { + testName: "when execute succeeds should return no error", + cmd: &domain.PasskeyCheckCommand{ + CheckPasskey: []byte{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: &domain.User{ + ID: "user-1", + Username: "testuser", + Human: &domain.HumanUser{ + DisplayName: "Test User", + Passkeys: []*domain.Passkey{ + {ID: "pkey-1", KeyID: []byte("key-id-1")}, + }, + }, + }, + FetchedSession: &domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com"}, + }, + }, + FinishLoginFn: finishLoginOKFn, + }, + sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository { + repo := domainmock.NewSessionRepo(ctrl) + idCondition := repo.IDCondition("session-1") + factorChange := repo.SetFactor(&domain.SessionFactorPasskey{LastVerifiedAt: time.Now(), UserVerified: true}) + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + idCondition, + factorChange). + Times(1). + Return(int64(1), nil) + return repo + }, + userRepo: func(ctrl *gomock.Controller) domain.UserRepository { + repo := domainmock.NewUserRepo(ctrl) + humanRepo := domainmock.NewHumanRepo(ctrl) + repo.EXPECT().Human().Times(3).Return(humanRepo) + updateConditions := database.And( + humanRepo.PrimaryKeyCondition("instance-1", "user-1"), + humanRepo.PasskeyConditions().IDCondition("pkey-1"), + ) + pkeySignCountChange := humanRepo.SetPasskeySignCount(5) + repo.EXPECT(). + Update(gomock.Any(), gomock.Any(), + updateConditions, + pkeySignCountChange). + Times(1). + Return(int64(1), nil) + return repo + }, + expectedError: nil, + expectedPasskeyID: "pkey-1", + expectedPasskeySignCount: 5, + expectedUserVerified: true, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + t.Parallel() + // Given + ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "") + ctrl := gomock.NewController(t) + + opts := &domain.InvokeOpts{} + domain.WithQueryExecutor(new(noopdb.Pool))(opts) + if tc.sessionRepo != nil { + domain.WithSessionRepo(tc.sessionRepo(ctrl))(opts) + } + if tc.userRepo != nil { + domain.WithUserRepo(tc.userRepo(ctrl))(opts) + } + + // Test + err := tc.cmd.Execute(ctx, opts) + + // Verify + assert.ErrorIs(t, err, tc.expectedError) + assert.Equal(t, tc.expectedPasskeyID, tc.cmd.PKeyID) + assert.Equal(t, tc.expectedPasskeySignCount, tc.cmd.PKeySignCount) + assert.Equal(t, tc.expectedUserVerified, tc.cmd.UserVerified) + if tc.cmd.CheckPasskey != nil && tc.expectedError == nil { + assert.NotZero(t, tc.cmd.LastVerifiedAt) + } + }) + } +} + +func TestPasskeyCheckCommand_Events(t *testing.T) { + t.Parallel() + + tt := []struct { + testName string + cmd *domain.PasskeyCheckCommand + + expectedEvents []eventstore.Command + }{ + { + testName: "when checkPasskey is nil should return no events", + cmd: &domain.PasskeyCheckCommand{}, + expectedEvents: []eventstore.Command{}, + }, + { + testName: "when user verification is required should return WebAuthNCheckedEvent and PasswordlessSignCountChangedEvent", + cmd: &domain.PasskeyCheckCommand{ + CheckPasskey: []byte{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: &domain.User{ + ID: "user-1", + Username: "testuser", + Human: &domain.HumanUser{ + DisplayName: "Test User", + Passkeys: []*domain.Passkey{ + {ID: "pkey-1", KeyID: []byte("key-id-1")}, + }, + }, + }, + FetchedSession: &domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com", UserVerification: old_domain.UserVerificationRequirementRequired}, + }, + }, + LastVerifiedAt: time.Now(), + UserVerified: true, + PKeyID: "pkey-1", + PKeySignCount: 5, + }, + expectedEvents: []eventstore.Command{ + session.NewWebAuthNCheckedEvent(t.Context(), nil, time.Now(), true), + user.NewHumanPasswordlessSignCountChangedEvent(t.Context(), nil, "pkey-1", 5), + }, + }, + { + testName: "when user verification is not required should return WebAuthNCheckedEvent and U2FSignCountChangedEvent", + cmd: &domain.PasskeyCheckCommand{ + CheckPasskey: []byte{}, + SessionID: "session-1", + InstanceID: "instance-1", + FetchedUser: &domain.User{ + ID: "user-1", + Username: "testuser", + Human: &domain.HumanUser{ + DisplayName: "Test User", + Passkeys: []*domain.Passkey{ + {ID: "pkey-2", KeyID: []byte("key-id-2")}, + }, + }, + }, + FetchedSession: &domain.Session{ + ID: "session-1", + UserID: "user-1", + Challenges: domain.SessionChallenges{ + &domain.SessionChallengePasskey{Challenge: "challenge", RPID: "example.com", UserVerification: old_domain.UserVerificationRequirementPreferred}, + }, + }, + LastVerifiedAt: time.Now(), + UserVerified: false, + PKeyID: "pkey-2", + PKeySignCount: 10, + }, + expectedEvents: []eventstore.Command{ + session.NewWebAuthNCheckedEvent(t.Context(), nil, time.Now(), false), + user.NewHumanU2FSignCountChangedEvent(t.Context(), nil, "pkey-2", 10), + }, + }, + } + + for _, tc := range tt { + t.Run(tc.testName, func(t *testing.T) { + // Given + ctx := authz.NewMockContext(tc.cmd.InstanceID, "", "") + + opts := &domain.InvokeOpts{} + + // Test + events, err := tc.cmd.Events(ctx, opts) + + // Verify + assert.NoError(t, err) + require.Len(t, events, len(tc.expectedEvents)) + for i, expectedType := range tc.expectedEvents { + assert.IsType(t, expectedType, events[i]) + switch expectedAssertedType := expectedType.(type) { + case *session.WebAuthNCheckedEvent: + actualAssertedType, ok := events[i].(*session.WebAuthNCheckedEvent) + require.True(t, ok) + assert.Equal(t, tc.cmd.LastVerifiedAt, actualAssertedType.CheckedAt) + assert.Equal(t, expectedAssertedType.UserVerified, actualAssertedType.UserVerified) + case *user.HumanPasswordlessSignCountChangedEvent: + actualAssertedType, ok := events[i].(*user.HumanPasswordlessSignCountChangedEvent) + require.True(t, ok) + assert.Equal(t, expectedAssertedType.WebAuthNTokenID, actualAssertedType.WebAuthNTokenID) + assert.Equal(t, expectedAssertedType.SignCount, actualAssertedType.SignCount) + case *user.HumanU2FSignCountChangedEvent: + actualAssertedType, ok := events[i].(*user.HumanU2FSignCountChangedEvent) + require.True(t, ok) + assert.Equal(t, expectedAssertedType.WebAuthNTokenID, actualAssertedType.WebAuthNTokenID) + assert.Equal(t, expectedAssertedType.SignCount, actualAssertedType.SignCount) + } + } + }) + } +} diff --git a/backend/v3/domain/session_check_password.go b/backend/v3/domain/session_check_password.go index b4c74c5b1..d217ab7f9 100644 --- a/backend/v3/domain/session_check_password.go +++ b/backend/v3/domain/session_check_password.go @@ -121,12 +121,8 @@ func (p *PasswordCheckCommand) Execute(ctx context.Context, opts *InvokeOpts) (e if changesErr != nil { return changesErr } - beginner, ok := opts.DB().(database.Beginner) - if !ok { - return zerrors.ThrowInternal(nil, "DOM-fEhd79", "database doesn't implement database.Beginner") - } - tx, txErr := beginner.Begin(ctx, nil) + tx, txErr := opts.StartTransaction(ctx, nil) if txErr != nil { return zerrors.ThrowInternal(txErr, "DOM-IR1vH2", "failed starting transaction") } diff --git a/backend/v3/domain/user.go b/backend/v3/domain/user.go index d74776088..496cb432e 100644 --- a/backend/v3/domain/user.go +++ b/backend/v3/domain/user.go @@ -207,7 +207,6 @@ type Passkey struct { } //go:generate enumer -type PasskeyType -transform lower -trimprefix PasskeyType -json -sql - type PasskeyType uint8 const ( diff --git a/backend/v3/domain/user_repo.go b/backend/v3/domain/user_repo.go index 3560c5e49..2fa2c0947 100644 --- a/backend/v3/domain/user_repo.go +++ b/backend/v3/domain/user_repo.go @@ -355,7 +355,7 @@ type HumanPasskeyConditions interface { IDCondition(passkeyID string) database.Condition KeyIDCondition(keyID string) database.Condition ChallengeCondition(challenge []byte) database.Condition - TypeCondition(passkeyType PasskeyType) database.Condition + TypeCondition(op database.TextOperation, passkeyType PasskeyType) database.Condition } type humanPasskeyChanges interface { diff --git a/backend/v3/storage/database/repository/user_passkey.go b/backend/v3/storage/database/repository/user_passkey.go index b635cee41..962f8d4ae 100644 --- a/backend/v3/storage/database/repository/user_passkey.go +++ b/backend/v3/storage/database/repository/user_passkey.go @@ -195,8 +195,8 @@ func (u userPasskey) KeyIDCondition(keyID string) database.Condition { } // TypeCondition implements [domain.HumanPasskeyConditions]. -func (u userPasskey) TypeCondition(passkeyType domain.PasskeyType) database.Condition { - return database.NewTextCondition(u.typeColumn(), database.TextOperationEqual, passkeyType.String()) +func (u userPasskey) TypeCondition(op database.TextOperation, passkeyType domain.PasskeyType) database.Condition { + return database.NewTextCondition(u.typeColumn(), op, passkeyType.String()) } // ------------------------------------------------------------- diff --git a/backend/v3/storage/database/repository/user_test.go b/backend/v3/storage/database/repository/user_test.go index 186bb4f58..008dee83b 100644 --- a/backend/v3/storage/database/repository/user_test.go +++ b/backend/v3/storage/database/repository/user_test.go @@ -1573,7 +1573,7 @@ func Test_user_ListConditions(t *testing.T) { opts: []database.QueryOption{ database.WithCondition(database.And( userRepo.InstanceIDCondition(instanceID1), - humanRepo.ExistsPasskey(humanRepo.PasskeyConditions().TypeCondition(domain.PasskeyTypeU2F)), + humanRepo.ExistsPasskey(humanRepo.PasskeyConditions().TypeCondition(database.TextOperationEqual, domain.PasskeyTypeU2F)), )), }, want: want{ diff --git a/cmd/start/start.go b/cmd/start/start.go index 6ad757379..26aaf2c36 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -268,6 +268,9 @@ func startZitadel(ctx context.Context, config *Config, masterKey string, server DisplayName: config.WebAuthNName, ExternalSecure: config.ExternalSecure, } + + new_domain.SetWebAuthNConfig(webAuthNConfig) + commands, err := command.StartCommands(ctx, eventstoreClient, cacheConnectors, diff --git a/internal/command/session_webauhtn.go b/internal/command/session_webauhtn.go index 0bbb68c99..b119f14e2 100644 --- a/internal/command/session_webauhtn.go +++ b/internal/command/session_webauhtn.go @@ -80,7 +80,7 @@ func (c *Commands) CheckWebAuthN(credentialAssertionData json.Marshaler) Session if err != nil && (credential == nil || credential.ID == nil) { return nil, err } - _, token := domain.GetTokenByKeyID(webAuthNTokens.tokens, credential.ID) + token := domain.GetTokenByKeyID(webAuthNTokens.tokens, credential.ID) if token == nil { return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Aej7i", "Errors.User.WebAuthN.NotFound") } diff --git a/internal/command/user_human_webauthn.go b/internal/command/user_human_webauthn.go index 60614d3e6..db1731693 100644 --- a/internal/command/user_human_webauthn.go +++ b/internal/command/user_human_webauthn.go @@ -462,7 +462,7 @@ func (c *Commands) finishWebAuthNLogin(ctx context.Context, userID, resourceOwne return nil, nil, 0, err } - _, token := domain.GetTokenByKeyID(tokens, credential.ID) + token := domain.GetTokenByKeyID(tokens, credential.ID) if token == nil { return nil, nil, 0, zerrors.ThrowPreconditionFailed(nil, "COMMAND-3b7zs", "Errors.User.WebAuthN.NotFound") } diff --git a/internal/domain/human_web_auth_n.go b/internal/domain/human_web_auth_n.go index 62c342491..a8428c594 100644 --- a/internal/domain/human_web_auth_n.go +++ b/internal/domain/human_web_auth_n.go @@ -62,13 +62,13 @@ func GetTokenToVerify(tokens []*WebAuthNToken) (int, *WebAuthNToken) { return -1, nil } -func GetTokenByKeyID(tokens []*WebAuthNToken, keyID []byte) (int, *WebAuthNToken) { - for i, token := range tokens { - if bytes.Compare(token.KeyID, keyID) == 0 { - return i, token +func GetTokenByKeyID(tokens []*WebAuthNToken, keyID []byte) *WebAuthNToken { + for _, token := range tokens { + if bytes.Equal(token.KeyID, keyID) { + return token } } - return -1, nil + return nil } type PasswordlessInitCodeState int32 diff --git a/internal/query/projection/user_relational.go b/internal/query/projection/user_relational.go index 14b25ba18..5448a496a 100644 --- a/internal/query/projection/user_relational.go +++ b/internal/query/projection/user_relational.go @@ -1079,9 +1079,7 @@ func (p *relationalTablesProjection) reducePasskeyAdded(event eventstore.Event) } repo := repository.HumanUserRepository() _, err := repo.Update(ctx, v3_sql.SQLTx(tx), - database.And( - repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), - ), + repo.PrimaryKeyCondition(e.Aggregate().InstanceID, e.Aggregate().ID), repo.AddPasskey(&domain.Passkey{ ID: e.WebAuthNTokenID, Challenge: []byte(e.Challenge), diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index 10d6fc52b..b41dc8ab8 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -181,6 +181,30 @@ func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN * return credential, nil } +func (w *Config) FinishLoginWithNewDomainModel(ctx context.Context, sessionData webauthn.SessionData, user webauthn.User, credentials []byte, rpID string) (*webauthn.Credential, error) { + assertionData, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(credentials)) + if err != nil { + logging.WithFields("error", tryExtractProtocolErrMsg(err)).Debug("webauthn assertion could not be parsed") + return nil, zerrors.ThrowInternal(err, "WEBAU-ytJJmg", "Errors.User.WebAuthN.ValidateLoginFailed") + } + webAuthNServer, err := w.serverFromContext(ctx, rpID, assertionData.Response.CollectedClientData.Origin) + if err != nil { + return nil, err + } + + credential, err := webAuthNServer.ValidateLogin(user, sessionData, assertionData) + if err != nil { + logging.WithFields("error", tryExtractProtocolErrMsg(err)).Debug("webauthn assertion failed") + return nil, zerrors.ThrowInternal(err, "WEBAU-zHfUKX", "Errors.User.WebAuthN.ValidateLoginFailed") + } + + if credential.Authenticator.CloneWarning { + return credential, zerrors.ThrowInternal(nil, "WEBAU-eDG7kQ", "Errors.User.WebAuthN.CloneWarning") + } + + return credential, nil +} + func (w *Config) serverFromContext(ctx context.Context, id, origin string) (*webauthn.WebAuthn, error) { config := w.config(id, origin) if id == "" {