feat(session): Passkey Check API with relation tables (#11858)

# 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 <fabienne@zitadel.com>
Co-authored-by: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com>
This commit is contained in:
Marco A.
2026-03-27 12:38:40 +01:00
committed by GitHub
co-authored by Fabienne Bühler Gayathri Vijayan
parent ec7a6d5526
commit 022bf74060
23 changed files with 1591 additions and 20 deletions
@@ -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())
}
@@ -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)
})
}
}
+6
View File
@@ -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
}
+20
View File
@@ -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
+72
View File
@@ -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)
})
}
}
+29
View File
@@ -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
+146
View File
@@ -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)
})
}
}
+4
View File
@@ -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)
}
+81
View File
@@ -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)
+158
View File
@@ -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)
})
}
}
+226
View File
@@ -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)
@@ -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)
}
}
})
}
}
+1 -5
View File
@@ -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")
}
-1
View File
@@ -207,7 +207,6 @@ type Passkey struct {
}
//go:generate enumer -type PasskeyType -transform lower -trimprefix PasskeyType -json -sql
type PasskeyType uint8
const (
+1 -1
View File
@@ -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 {
@@ -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())
}
// -------------------------------------------------------------
@@ -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{
+3
View File
@@ -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,
+1 -1
View File
@@ -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")
}
+1 -1
View File
@@ -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")
}
+5 -5
View File
@@ -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
+1 -3
View File
@@ -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),
+24
View File
@@ -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 == "" {