mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
feat: session challenge passkey for the RT model (#11944)
# Which Problems Are Solved Add Passkey challenge needed for Create/Set session in the RT model. # How the Problems Are Solved Introduce `PasskeyChallengeCommand` (validate/execute/events) for session creation checks Add a generic `BeginWebAuthNLogin` helper that returns session data + assertion JSON bytes. Add unit tests # Additional Changes N/A # Additional Context - Related to https://github.com/zitadel/zitadel/issues/11035 --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Marco A. <[email protected]>
This commit is contained in:
co-authored by
Copilot
Marco A.
parent
701bca6003
commit
c7f92a6887
@@ -0,0 +1,33 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/zitadel/zitadel/backend/v3/domain"
|
||||||
|
old_domain "github.com/zitadel/zitadel/internal/domain"
|
||||||
|
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ChallengePasskeyGRPCToDomain(challengePasskey *session_grpc.RequestChallenges_WebAuthN) *domain.ChallengeTypePasskey {
|
||||||
|
if challengePasskey == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
userVerificationRequirement := pbUserVerificationRequirementToDomain(challengePasskey.GetUserVerificationRequirement())
|
||||||
|
return &domain.ChallengeTypePasskey{
|
||||||
|
Domain: challengePasskey.GetDomain(),
|
||||||
|
UserVerificationRequirement: userVerificationRequirement,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pbUserVerificationRequirementToDomain(req session_grpc.UserVerificationRequirement) old_domain.UserVerificationRequirement {
|
||||||
|
switch req {
|
||||||
|
case session_grpc.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_UNSPECIFIED:
|
||||||
|
return old_domain.UserVerificationRequirementUnspecified
|
||||||
|
case session_grpc.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED:
|
||||||
|
return old_domain.UserVerificationRequirementRequired
|
||||||
|
case session_grpc.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED:
|
||||||
|
return old_domain.UserVerificationRequirementPreferred
|
||||||
|
case session_grpc.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED:
|
||||||
|
return old_domain.UserVerificationRequirementDiscouraged
|
||||||
|
default:
|
||||||
|
return old_domain.UserVerificationRequirementUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package convert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/zitadel/zitadel/backend/v3/domain"
|
||||||
|
old_domain "github.com/zitadel/zitadel/internal/domain"
|
||||||
|
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChallengePasskeyGRPCToDomain(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
challengePasskey *session_grpc.RequestChallenges_WebAuthN
|
||||||
|
want *domain.ChallengeTypePasskey
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no challenge passkey",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "challenge passkey - user verification required",
|
||||||
|
challengePasskey: &session_grpc.RequestChallenges_WebAuthN{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: session_grpc.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_REQUIRED,
|
||||||
|
},
|
||||||
|
want: &domain.ChallengeTypePasskey{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementRequired,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "challenge passkey - user verification preferred",
|
||||||
|
challengePasskey: &session_grpc.RequestChallenges_WebAuthN{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: session_grpc.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_PREFERRED,
|
||||||
|
},
|
||||||
|
want: &domain.ChallengeTypePasskey{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "challenge passkey - user verification unspecified",
|
||||||
|
challengePasskey: &session_grpc.RequestChallenges_WebAuthN{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: session_grpc.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_UNSPECIFIED,
|
||||||
|
},
|
||||||
|
want: &domain.ChallengeTypePasskey{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementUnspecified,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "challenge passkey - user verification discouraged",
|
||||||
|
challengePasskey: &session_grpc.RequestChallenges_WebAuthN{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: session_grpc.UserVerificationRequirement_USER_VERIFICATION_REQUIREMENT_DISCOURAGED,
|
||||||
|
},
|
||||||
|
want: &domain.ChallengeTypePasskey{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementDiscouraged,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "challenge passkey - user verification with no value",
|
||||||
|
challengePasskey: &session_grpc.RequestChallenges_WebAuthN{
|
||||||
|
Domain: "example.com",
|
||||||
|
},
|
||||||
|
want: &domain.ChallengeTypePasskey{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementUnspecified,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
assert.Equal(t, tt.want, ChallengePasskeyGRPCToDomain(tt.challengePasskey))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
|
||||||
|
"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/zerrors"
|
||||||
|
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ChallengeTypePasskey struct {
|
||||||
|
Domain string
|
||||||
|
UserVerificationRequirement old_domain.UserVerificationRequirement
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ Commander = (*PasskeyChallengeCommand)(nil)
|
||||||
|
var _ Transactional = (*PasskeyChallengeCommand)(nil)
|
||||||
|
|
||||||
|
type beginLoginFn func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error)
|
||||||
|
|
||||||
|
// PasskeyChallengeCommand handles the passkey challenge creation during session creation.
|
||||||
|
// It supports both U2F and passwordless flows based on user verification requirements.
|
||||||
|
type PasskeyChallengeCommand struct {
|
||||||
|
ChallengeTypePasskey *ChallengeTypePasskey
|
||||||
|
|
||||||
|
SessionID string
|
||||||
|
InstanceID string
|
||||||
|
|
||||||
|
user *User
|
||||||
|
beginLogin beginLoginFn
|
||||||
|
|
||||||
|
challengePasskey *SessionChallengePasskey // the generated passkey challenge that is stored in the session.
|
||||||
|
webAuthNChallenge *session_grpc.Challenges_WebAuthN // challenge to be set in the CreateSessionResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPasskeyChallengeCommand creates a new PasskeyChallengeCommand.
|
||||||
|
func NewPasskeyChallengeCommand(
|
||||||
|
sessionID,
|
||||||
|
instanceID string,
|
||||||
|
challengePasskey *ChallengeTypePasskey,
|
||||||
|
beginLoginFn beginLoginFn,
|
||||||
|
) (*PasskeyChallengeCommand, error) {
|
||||||
|
|
||||||
|
if beginLoginFn == nil {
|
||||||
|
if webauthnConfig == nil {
|
||||||
|
return nil, zerrors.ThrowInternal(nil, "DOM-jwk5Pe", "begin webauthn login function not set")
|
||||||
|
}
|
||||||
|
beginLoginFn = webauthnConfig.BeginWebAuthNLogin
|
||||||
|
}
|
||||||
|
return &PasskeyChallengeCommand{
|
||||||
|
ChallengeTypePasskey: challengePasskey,
|
||||||
|
SessionID: sessionID,
|
||||||
|
InstanceID: instanceID,
|
||||||
|
beginLogin: beginLoginFn,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequiresTransaction implements [Transactional].
|
||||||
|
func (p *PasskeyChallengeCommand) RequiresTransaction() {}
|
||||||
|
|
||||||
|
// Validate implements [Commander].
|
||||||
|
// It retrieves the session, the user, and the user's passkeys based on the user verification requirement,
|
||||||
|
// and validates that the user is active.
|
||||||
|
func (p *PasskeyChallengeCommand) Validate(ctx context.Context, opts *InvokeOpts) (err error) {
|
||||||
|
if p.ChallengeTypePasskey == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.SessionID == "" {
|
||||||
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-EVo5yE", "Errors.Missing.SessionID")
|
||||||
|
}
|
||||||
|
if p.InstanceID == "" {
|
||||||
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-sh8xvQ", "Errors.Missing.InstanceID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// get session
|
||||||
|
sessionRepo := opts.sessionRepo
|
||||||
|
retrievedSession, err := sessionRepo.Get(
|
||||||
|
ctx,
|
||||||
|
opts.DB(),
|
||||||
|
database.WithCondition(sessionRepo.PrimaryKeyCondition(p.InstanceID, p.SessionID)),
|
||||||
|
)
|
||||||
|
if err := handleGetError(err, "DOM-zy4hYC", objectTypeSession); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if retrievedSession.UserID == "" {
|
||||||
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-uVyrt2", "Errors.Missing.Session.UserID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieve user and their passkeys based on user verification requirement
|
||||||
|
passkeyType := determinePasskeyType(p.ChallengeTypePasskey.UserVerificationRequirement)
|
||||||
|
userRepo := opts.userRepo
|
||||||
|
retrievedUser, err := userRepo.Get(
|
||||||
|
ctx,
|
||||||
|
opts.DB(),
|
||||||
|
database.WithCondition(userRepo.PrimaryKeyCondition(p.InstanceID, retrievedSession.UserID)),
|
||||||
|
database.WithCondition(userRepo.Human().PasskeyConditions().TypeCondition(database.TextOperationEqual, passkeyType)),
|
||||||
|
)
|
||||||
|
if err := handleGetError(err, "DOM-8cGMtd", objectTypeUser); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure the user is a human user
|
||||||
|
if retrievedUser.Human == nil {
|
||||||
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-nd3f4", "Errors.User.NotHuman")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure the user is active
|
||||||
|
if retrievedUser.State != UserStateActive {
|
||||||
|
return zerrors.ThrowPreconditionFailed(nil, "DOM-bnxBdS", "Errors.User.NotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.user = retrievedUser
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute implements [Commander].
|
||||||
|
// It begins the WebAuthN login process and updates the session with the passkey challenge.
|
||||||
|
func (p *PasskeyChallengeCommand) Execute(ctx context.Context, opts *InvokeOpts) (err error) {
|
||||||
|
if p.ChallengeTypePasskey == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// begin webauthn login
|
||||||
|
sessionData, credentialAssertionData, rpID, err := p.beginWebAuthNLogin(ctx, p.ChallengeTypePasskey.UserVerificationRequirement)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// to set the challenge for CreateSessionResponse
|
||||||
|
webAuthNChallenge := &session_grpc.Challenges_WebAuthN{
|
||||||
|
PublicKeyCredentialRequestOptions: new(structpb.Struct),
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(credentialAssertionData, webAuthNChallenge.PublicKeyCredentialRequestOptions); err != nil {
|
||||||
|
return zerrors.ThrowInternal(err, "DOM-liSCA4", "Errors.Unmarshal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the session with the passkey challenge
|
||||||
|
challengePasskey := &SessionChallengePasskey{
|
||||||
|
Challenge: sessionData.Challenge,
|
||||||
|
AllowedCredentialIDs: sessionData.AllowedCredentialIDs,
|
||||||
|
UserVerification: p.ChallengeTypePasskey.UserVerificationRequirement,
|
||||||
|
RPID: rpID,
|
||||||
|
LastChallengedAt: time.Now(),
|
||||||
|
}
|
||||||
|
sessionRepo := opts.sessionRepo
|
||||||
|
updated, err := sessionRepo.Update(
|
||||||
|
ctx,
|
||||||
|
opts.DB(),
|
||||||
|
sessionRepo.PrimaryKeyCondition(p.InstanceID, p.SessionID),
|
||||||
|
sessionRepo.SetChallenge(challengePasskey),
|
||||||
|
)
|
||||||
|
if err := handleUpdateError(err, expectedUpdatedRows, updated, "DOM-yd3f4", objectTypeSession); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.webAuthNChallenge = webAuthNChallenge
|
||||||
|
p.challengePasskey = challengePasskey
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events implements [Commander].
|
||||||
|
// It creates a WebAuthN challenged event if a passkey challenge was requested and created.
|
||||||
|
func (p *PasskeyChallengeCommand) Events(ctx context.Context, _ *InvokeOpts) ([]eventstore.Command, error) {
|
||||||
|
if p.ChallengeTypePasskey == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return []eventstore.Command{
|
||||||
|
session.NewWebAuthNChallengedEvent(
|
||||||
|
ctx,
|
||||||
|
&session.NewAggregate(p.SessionID, p.InstanceID).Aggregate,
|
||||||
|
p.challengePasskey.Challenge,
|
||||||
|
p.challengePasskey.AllowedCredentialIDs,
|
||||||
|
p.challengePasskey.UserVerification,
|
||||||
|
p.challengePasskey.RPID,
|
||||||
|
),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasskeyChallengeCommand) GetWebAuthNChallenge() *session_grpc.Challenges_WebAuthN {
|
||||||
|
return p.webAuthNChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasskeyChallengeCommand) String() string {
|
||||||
|
return "PasskeyChallengeCommand"
|
||||||
|
}
|
||||||
|
|
||||||
|
// beginWebAuthNLogin starts the WebAuthN login process for the user with the specified user verification requirement.
|
||||||
|
func (p *PasskeyChallengeCommand) beginWebAuthNLogin(ctx context.Context, userVerificationDomain old_domain.UserVerificationRequirement) (*webauthn.SessionData, []byte, string, error) {
|
||||||
|
webUser := &webAuthNUser{
|
||||||
|
userID: p.user.ID,
|
||||||
|
username: p.user.Username,
|
||||||
|
displayName: p.user.Human.DisplayName,
|
||||||
|
creds: PasskeysToCredentials(ctx, p.user.Human.Passkeys, p.ChallengeTypePasskey.Domain),
|
||||||
|
}
|
||||||
|
sessionData, credentialAssertionData, rpID, err := p.beginLogin(
|
||||||
|
ctx,
|
||||||
|
webUser,
|
||||||
|
p.ChallengeTypePasskey.Domain,
|
||||||
|
UserVerificationFromDomain(userVerificationDomain),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", err
|
||||||
|
}
|
||||||
|
return sessionData, credentialAssertionData, rpID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// determinePasskeyType determines the passkey type (U2F or Passwordless) based on the user verification requirement.
|
||||||
|
func determinePasskeyType(userVerificationRequirement old_domain.UserVerificationRequirement) PasskeyType {
|
||||||
|
passkeyType := PasskeyTypeU2F
|
||||||
|
if userVerificationRequirement == old_domain.UserVerificationRequirementRequired {
|
||||||
|
passkeyType = PasskeyTypePasswordless
|
||||||
|
}
|
||||||
|
return passkeyType
|
||||||
|
}
|
||||||
@@ -0,0 +1,986 @@
|
|||||||
|
package domain_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/mock/gomock"
|
||||||
|
"google.golang.org/protobuf/types/known/structpb"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"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/zerrors"
|
||||||
|
session_grpc "github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewPasskeyChallengeCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sessionID string
|
||||||
|
instanceID string
|
||||||
|
challengeTypePasskey *domain.ChallengeTypePasskey
|
||||||
|
webAuthNBeginLogin func(
|
||||||
|
ctx context.Context,
|
||||||
|
user webauthn.User,
|
||||||
|
rpID string,
|
||||||
|
userVerification protocol.UserVerificationRequirement,
|
||||||
|
) (sessionData *webauthn.SessionData,
|
||||||
|
cred []byte,
|
||||||
|
relyingPartyID string,
|
||||||
|
err error)
|
||||||
|
wantCmd *domain.PasskeyChallengeCommand
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing begin webauthn login function",
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementRequired,
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowInternal(nil, "DOM-jwk5Pe", "begin webauthn login function not set"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid passkey challenge command",
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementRequired,
|
||||||
|
},
|
||||||
|
wantCmd: &domain.PasskeyChallengeCommand{
|
||||||
|
InstanceID: "instance-1",
|
||||||
|
SessionID: "session-1",
|
||||||
|
ChallengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
Domain: "example.com",
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementRequired,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cmd, err := domain.NewPasskeyChallengeCommand(tt.sessionID, tt.instanceID, tt.challengeTypePasskey, tt.webAuthNBeginLogin)
|
||||||
|
if tt.wantErr != nil {
|
||||||
|
assert.ErrorIs(t, err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantCmd.SessionID, cmd.SessionID)
|
||||||
|
assert.Equal(t, tt.wantCmd.InstanceID, cmd.InstanceID)
|
||||||
|
assert.Equal(t, tt.wantCmd.ChallengeTypePasskey, cmd.ChallengeTypePasskey)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyChallengeCommand_Validate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sessionID string
|
||||||
|
instanceID string
|
||||||
|
webAuthNBeginLogin func(
|
||||||
|
ctx context.Context,
|
||||||
|
user webauthn.User,
|
||||||
|
rpID string,
|
||||||
|
userVerification protocol.UserVerificationRequirement,
|
||||||
|
) (sessionData *webauthn.SessionData,
|
||||||
|
cred []byte,
|
||||||
|
relyingPartyID string,
|
||||||
|
err error)
|
||||||
|
challengeTypePasskey *domain.ChallengeTypePasskey
|
||||||
|
userRepo func(ctrl *gomock.Controller) domain.UserRepository
|
||||||
|
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no request passkey challenge",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no session id",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-EVo5yE", "Errors.Missing.SessionID"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no instance id",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-sh8xvQ", "Errors.Missing.InstanceID"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to fetch session",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
repo.EXPECT().
|
||||||
|
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
repo.PrimaryKeyCondition("instance-1", "session-1"),
|
||||||
|
),
|
||||||
|
)).
|
||||||
|
Times(1).
|
||||||
|
Return(nil, assert.AnError)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-zy4hYC", "failed fetching Session"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session not found",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
repo.EXPECT().
|
||||||
|
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
repo.PrimaryKeyCondition("instance-1", "session-1"),
|
||||||
|
),
|
||||||
|
)).
|
||||||
|
Times(1).
|
||||||
|
Return(nil, new(database.NoRowFoundError))
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowNotFound(new(database.NoRowFoundError), "DOM-zy4hYC", "Session not found"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session without user id",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
repo.EXPECT().
|
||||||
|
Get(gomock.Any(), gomock.Any(), dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
repo.PrimaryKeyCondition("instance-1", "session-1"),
|
||||||
|
),
|
||||||
|
)).
|
||||||
|
Times(1).
|
||||||
|
Return(&domain.Session{
|
||||||
|
UserID: "",
|
||||||
|
}, nil)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-uVyrt2", "Errors.Missing.Session.UserID"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to fetch user",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
repo := domainmock.NewUserRepo(ctrl)
|
||||||
|
|
||||||
|
humanRepo := domainmock.NewHumanRepo(ctrl)
|
||||||
|
repo.EXPECT().Human().Times(1).Return(humanRepo)
|
||||||
|
|
||||||
|
repo.EXPECT().
|
||||||
|
Get(gomock.Any(),
|
||||||
|
gomock.Any(),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
repo.PrimaryKeyCondition("instance-1", "user-1"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
humanRepo.PasskeyConditions().TypeCondition(database.TextOperationEqual, domain.PasskeyTypeU2F),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).
|
||||||
|
Times(1).
|
||||||
|
Return(nil, assert.AnError)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-8cGMtd", "failed fetching User"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user not found",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
repo := domainmock.NewUserRepo(ctrl)
|
||||||
|
humanRepo := domainmock.NewHumanRepo(ctrl)
|
||||||
|
repo.EXPECT().Human().Times(1).Return(humanRepo)
|
||||||
|
|
||||||
|
repo.EXPECT().
|
||||||
|
Get(gomock.Any(),
|
||||||
|
gomock.Any(),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
repo.PrimaryKeyCondition("instance-1", "user-1"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
humanRepo.PasskeyConditions().TypeCondition(database.TextOperationEqual, domain.PasskeyTypeU2F),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).
|
||||||
|
Times(1).
|
||||||
|
Return(nil, new(database.NoRowFoundError))
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowNotFound(new(database.NoRowFoundError), "DOM-8cGMtd", "User not found"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user not human",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
repo := domainmock.NewUserRepo(ctrl)
|
||||||
|
humanRepo := domainmock.NewHumanRepo(ctrl)
|
||||||
|
repo.EXPECT().Human().Times(1).Return(humanRepo)
|
||||||
|
|
||||||
|
repo.EXPECT().
|
||||||
|
Get(gomock.Any(),
|
||||||
|
gomock.Any(),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
repo.PrimaryKeyCondition("instance-1", "user-1"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
humanRepo.PasskeyConditions().TypeCondition(database.TextOperationEqual, domain.PasskeyTypeU2F),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).
|
||||||
|
Times(1).
|
||||||
|
Return(&domain.User{}, nil)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-nd3f4", "Errors.User.NotHuman"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user not active",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
repo := domainmock.NewUserRepo(ctrl)
|
||||||
|
humanRepo := domainmock.NewHumanRepo(ctrl)
|
||||||
|
repo.EXPECT().Human().Times(1).Return(humanRepo)
|
||||||
|
|
||||||
|
repo.EXPECT().
|
||||||
|
Get(gomock.Any(),
|
||||||
|
gomock.Any(),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
repo.PrimaryKeyCondition("instance-1", "user-1"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
humanRepo.PasskeyConditions().TypeCondition(database.TextOperationEqual, domain.PasskeyTypeU2F),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).
|
||||||
|
Times(1).
|
||||||
|
Return(&domain.User{
|
||||||
|
State: domain.UserStateInactive,
|
||||||
|
Human: &domain.HumanUser{},
|
||||||
|
}, nil)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowPreconditionFailed(nil, "DOM-bnxBdS", "Errors.User.NotFound"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid request passkey challenge",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
sessionID: "session-1",
|
||||||
|
instanceID: "instance-1",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := authz.NewMockContext("instance-1", "", "")
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
cmd, err := domain.NewPasskeyChallengeCommand(
|
||||||
|
tt.sessionID,
|
||||||
|
tt.instanceID,
|
||||||
|
tt.challengeTypePasskey,
|
||||||
|
tt.webAuthNBeginLogin,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
opts := &domain.InvokeOpts{
|
||||||
|
Invoker: domain.NewTransactionInvoker(nil),
|
||||||
|
}
|
||||||
|
if tt.userRepo != nil {
|
||||||
|
domain.WithUserRepo(tt.userRepo(ctrl))(opts)
|
||||||
|
}
|
||||||
|
if tt.sessionRepo != nil {
|
||||||
|
domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts)
|
||||||
|
}
|
||||||
|
err = cmd.Validate(ctx, opts)
|
||||||
|
assert.ErrorIs(t, err, tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyChallengeCommand_Execute(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
challengedAt := time.Now()
|
||||||
|
credentialAssertionDataRequired := []byte(`{"publicKey":{"challenge":"Y2hhbGxlbmdl","timeout":60000,"rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cGFzc2tleS1pZA"}],"userVerification":"required"}}`)
|
||||||
|
credentialAssertionDataPreferred := []byte(`{"publicKey":{"challenge":"Y2hhbGxlbmdl","timeout":60000,"rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cGFzc2tleS1pZA"}],"userVerification":"preferred"}}`)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
challengeTypePasskey *domain.ChallengeTypePasskey
|
||||||
|
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
|
||||||
|
userRepo func(ctrl *gomock.Controller) domain.UserRepository
|
||||||
|
webAuthNBeginLogin func(
|
||||||
|
ctx context.Context,
|
||||||
|
user webauthn.User,
|
||||||
|
rpID string,
|
||||||
|
userVerification protocol.UserVerificationRequirement,
|
||||||
|
) (sessionData *webauthn.SessionData,
|
||||||
|
cred []byte,
|
||||||
|
relyingPartyID string,
|
||||||
|
err error)
|
||||||
|
wantErr error
|
||||||
|
wantWebAuthNChallenge *session_grpc.Challenges_WebAuthN
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no request passkey challenge",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, credentialAssertionDataPreferred, "example.com", nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to begin webauthn login",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return nil, nil, "", assert.AnError
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
wantErr: assert.AnError,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid credential assertion data",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "challenge",
|
||||||
|
}, []byte("invalid"), "rpID", nil
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowInternal(nil, "DOM-liSCA4", "Errors.Unmarshal"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to update session",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, credentialAssertionDataPreferred, "example.com", nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
LastChallengedAt: time.Now(),
|
||||||
|
})
|
||||||
|
updateSessionFailedExpectation(repo, challenge, assert.AnError, 0)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowInternal(assert.AnError, "DOM-yd3f4", "failed updating Session"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to update session - no rows updated",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, credentialAssertionDataPreferred, "example.com", nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
LastChallengedAt: challengedAt,
|
||||||
|
})
|
||||||
|
updateSessionFailedExpectation(repo, challenge, nil, 0)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowNotFound(nil, "DOM-yd3f4", "Session not found"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to update session - more than 1 row updated",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, credentialAssertionDataPreferred, "example.com", nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
LastChallengedAt: challengedAt,
|
||||||
|
})
|
||||||
|
updateSessionFailedExpectation(repo, challenge, nil, 2)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantErr: zerrors.ThrowInternal(nil, "DOM-yd3f4", "unexpected number of rows updated"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session updated successfully with the passkey challenge - required user verification",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementRequired,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, credentialAssertionDataRequired, "example.com", nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypePasswordless)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementRequired,
|
||||||
|
LastChallengedAt: time.Now(),
|
||||||
|
})
|
||||||
|
updateSessionSucceededExpectation(repo, challenge)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantWebAuthNChallenge: getChallengePasskey(t, credentialAssertionDataRequired),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session updated successfully with the passkey challenge - preferred user verification",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, credentialAssertionDataPreferred, "example.com", nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
LastChallengedAt: time.Now(),
|
||||||
|
})
|
||||||
|
updateSessionSucceededExpectation(repo, challenge)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantWebAuthNChallenge: getChallengePasskey(t, credentialAssertionDataPreferred),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session updated successfully with the passkey challenge - unspecified user verification",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementUnspecified,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, credentialAssertionDataPreferred, "example.com", nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementUnspecified,
|
||||||
|
LastChallengedAt: time.Now(),
|
||||||
|
})
|
||||||
|
updateSessionSucceededExpectation(repo, challenge)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantWebAuthNChallenge: getChallengePasskey(t, credentialAssertionDataPreferred),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "session updated successfully with the passkey challenge - discouraged user verification",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementDiscouraged,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, credentialAssertionDataPreferred, "example.com", nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementDiscouraged,
|
||||||
|
LastChallengedAt: time.Now(),
|
||||||
|
})
|
||||||
|
updateSessionSucceededExpectation(repo, challenge)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantWebAuthNChallenge: getChallengePasskey(t, credentialAssertionDataPreferred),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := authz.NewMockContext("instance-1", "", "")
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
cmd, err := domain.NewPasskeyChallengeCommand(
|
||||||
|
"session-1",
|
||||||
|
"instance-1",
|
||||||
|
tt.challengeTypePasskey,
|
||||||
|
tt.webAuthNBeginLogin,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
opts := &domain.InvokeOpts{
|
||||||
|
Invoker: domain.NewTransactionInvoker(nil),
|
||||||
|
}
|
||||||
|
if tt.sessionRepo != nil {
|
||||||
|
domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts)
|
||||||
|
}
|
||||||
|
if tt.userRepo != nil {
|
||||||
|
domain.WithUserRepo(tt.userRepo(ctrl))(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// to fetch/validate session and user before calling Execute
|
||||||
|
err = cmd.Validate(ctx, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = cmd.Execute(ctx, opts)
|
||||||
|
assert.ErrorIs(t, err, tt.wantErr)
|
||||||
|
assert.Equal(t, tt.wantWebAuthNChallenge, cmd.GetWebAuthNChallenge())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasskeyChallengeCommand_Events(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := authz.NewMockContext("instance-1", "", "")
|
||||||
|
challengedAt := time.Now()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
challengeTypePasskey *domain.ChallengeTypePasskey
|
||||||
|
sessionRepo func(ctrl *gomock.Controller) domain.SessionRepository
|
||||||
|
userRepo func(ctrl *gomock.Controller) domain.UserRepository
|
||||||
|
webAuthNBeginLogin func(
|
||||||
|
ctx context.Context,
|
||||||
|
user webauthn.User,
|
||||||
|
rpID string,
|
||||||
|
userVerification protocol.UserVerificationRequirement,
|
||||||
|
) (sessionData *webauthn.SessionData,
|
||||||
|
cred []byte,
|
||||||
|
relyingPartyID string,
|
||||||
|
err error)
|
||||||
|
wantEvent eventstore.Command
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no request passkey challenge",
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
}, []byte("assertion data"), "example.com", nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid request passkey challenge - preferred user verification",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{Challenge: "Y2hhbGxlbmdl"},
|
||||||
|
[]byte(`{"publicKey":{"challenge":"Y2hhbGxlbmdl","timeout":60000,"rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cGFzc2tleS1pZA"}],"userVerification":"preferred"}}`),
|
||||||
|
"example.com",
|
||||||
|
nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementPreferred,
|
||||||
|
LastChallengedAt: challengedAt,
|
||||||
|
})
|
||||||
|
updateSessionSucceededExpectation(repo, challenge)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantEvent: session.NewWebAuthNChallengedEvent(ctx,
|
||||||
|
&session.NewAggregate("session-1", "instance-1").Aggregate,
|
||||||
|
"Y2hhbGxlbmdl",
|
||||||
|
nil,
|
||||||
|
old_domain.UserVerificationRequirementPreferred,
|
||||||
|
"example.com"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid request passkey challenge - required user verification",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementRequired,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{Challenge: "Y2hhbGxlbmdl"},
|
||||||
|
[]byte(`{"publicKey":{"challenge":"Y2hhbGxlbmdl","timeout":60000,"rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cGFzc2tleS1pZA"}],"userVerification":"required"}}`),
|
||||||
|
"example.com",
|
||||||
|
nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypePasswordless)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementRequired,
|
||||||
|
LastChallengedAt: challengedAt,
|
||||||
|
})
|
||||||
|
updateSessionSucceededExpectation(repo, challenge)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantEvent: session.NewWebAuthNChallengedEvent(ctx,
|
||||||
|
&session.NewAggregate("session-1", "instance-1").Aggregate,
|
||||||
|
"Y2hhbGxlbmdl",
|
||||||
|
nil,
|
||||||
|
old_domain.UserVerificationRequirementRequired,
|
||||||
|
"example.com"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid request passkey challenge - unspecified user verification",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementUnspecified,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{Challenge: "Y2hhbGxlbmdl"},
|
||||||
|
[]byte(`{"publicKey":{"challenge":"Y2hhbGxlbmdl","timeout":60000,"rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cGFzc2tleS1pZA"}],"userVerification":"preferred"}}`),
|
||||||
|
"example.com",
|
||||||
|
nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementUnspecified,
|
||||||
|
LastChallengedAt: challengedAt,
|
||||||
|
})
|
||||||
|
updateSessionSucceededExpectation(repo, challenge)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantEvent: session.NewWebAuthNChallengedEvent(ctx,
|
||||||
|
&session.NewAggregate("session-1", "instance-1").Aggregate,
|
||||||
|
"Y2hhbGxlbmdl",
|
||||||
|
nil,
|
||||||
|
old_domain.UserVerificationRequirementUnspecified,
|
||||||
|
"example.com"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid request passkey challenge - discouraged user verification",
|
||||||
|
challengeTypePasskey: &domain.ChallengeTypePasskey{
|
||||||
|
UserVerificationRequirement: old_domain.UserVerificationRequirementDiscouraged,
|
||||||
|
},
|
||||||
|
webAuthNBeginLogin: func(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
return &webauthn.SessionData{Challenge: "Y2hhbGxlbmdl"},
|
||||||
|
[]byte(`{"publicKey":{"challenge":"Y2hhbGxlbmdl","timeout":60000,"rpId":"example.com","allowCredentials":[{"type":"public-key","id":"cGFzc2tleS1pZA"}],"userVerification":"preferred"}}`),
|
||||||
|
"example.com",
|
||||||
|
nil
|
||||||
|
},
|
||||||
|
userRepo: func(ctrl *gomock.Controller) domain.UserRepository {
|
||||||
|
return getUserByPasskeyType(ctrl, domain.PasskeyTypeU2F)
|
||||||
|
},
|
||||||
|
sessionRepo: func(ctrl *gomock.Controller) domain.SessionRepository {
|
||||||
|
repo := domainmock.NewSessionRepo(ctrl)
|
||||||
|
getSessionSucceededExpectation(repo)
|
||||||
|
challenge := repo.SetChallenge(&domain.SessionChallengePasskey{
|
||||||
|
Challenge: "Y2hhbGxlbmdl",
|
||||||
|
RPID: "example.com",
|
||||||
|
UserVerification: old_domain.UserVerificationRequirementDiscouraged,
|
||||||
|
LastChallengedAt: challengedAt,
|
||||||
|
})
|
||||||
|
updateSessionSucceededExpectation(repo, challenge)
|
||||||
|
return repo
|
||||||
|
},
|
||||||
|
wantEvent: session.NewWebAuthNChallengedEvent(ctx,
|
||||||
|
&session.NewAggregate("session-1", "instance-1").Aggregate,
|
||||||
|
"Y2hhbGxlbmdl",
|
||||||
|
nil,
|
||||||
|
old_domain.UserVerificationRequirementDiscouraged,
|
||||||
|
"example.com"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := authz.NewMockContext("instance-1", "", "")
|
||||||
|
ctrl := gomock.NewController(t)
|
||||||
|
cmd, err := domain.NewPasskeyChallengeCommand(
|
||||||
|
"session-1",
|
||||||
|
"instance-1",
|
||||||
|
tt.challengeTypePasskey,
|
||||||
|
tt.webAuthNBeginLogin,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
opts := &domain.InvokeOpts{
|
||||||
|
Invoker: domain.NewTransactionInvoker(nil),
|
||||||
|
}
|
||||||
|
if tt.sessionRepo != nil {
|
||||||
|
domain.WithSessionRepo(tt.sessionRepo(ctrl))(opts)
|
||||||
|
}
|
||||||
|
if tt.userRepo != nil {
|
||||||
|
domain.WithUserRepo(tt.userRepo(ctrl))(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// to fetch/validate session and user before calling Execute
|
||||||
|
err = cmd.Validate(ctx, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// to update session before calling Execute
|
||||||
|
err = cmd.Execute(ctx, opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
events, err := cmd.Events(ctx, opts)
|
||||||
|
assert.ErrorIs(t, err, tt.wantErr)
|
||||||
|
if tt.wantEvent != nil {
|
||||||
|
require.Len(t, events, 1)
|
||||||
|
assert.Equal(t, tt.wantEvent, events[0])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.Empty(t, events)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getChallengePasskey(t *testing.T, data []byte) *session_grpc.Challenges_WebAuthN {
|
||||||
|
webAuthNChallenge := &session_grpc.Challenges_WebAuthN{
|
||||||
|
PublicKeyCredentialRequestOptions: new(structpb.Struct),
|
||||||
|
}
|
||||||
|
err := json.Unmarshal(data, webAuthNChallenge.PublicKeyCredentialRequestOptions)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return webAuthNChallenge
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserByPasskeyType(ctrl *gomock.Controller, passkeyType domain.PasskeyType) *domainmock.UserRepo {
|
||||||
|
repo := domainmock.NewUserRepo(ctrl)
|
||||||
|
humanRepo := domainmock.NewHumanRepo(ctrl)
|
||||||
|
repo.EXPECT().Human().Times(1).Return(humanRepo)
|
||||||
|
|
||||||
|
repo.EXPECT().
|
||||||
|
Get(gomock.Any(),
|
||||||
|
gomock.Any(),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
repo.PrimaryKeyCondition("instance-1", "user-1"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
dbmock.QueryOptions(
|
||||||
|
database.WithCondition(
|
||||||
|
humanRepo.PasskeyConditions().TypeCondition(database.TextOperationEqual, passkeyType),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).
|
||||||
|
Times(1).
|
||||||
|
Return(&domain.User{
|
||||||
|
ID: "user-id",
|
||||||
|
InstanceID: "instance-1",
|
||||||
|
OrganizationID: "organization-id",
|
||||||
|
Username: "username",
|
||||||
|
Human: &domain.HumanUser{
|
||||||
|
Passkeys: []*domain.Passkey{
|
||||||
|
{
|
||||||
|
ID: "passkey-id",
|
||||||
|
PublicKey: []byte("public-key"),
|
||||||
|
Name: "My Passkey",
|
||||||
|
Type: passkeyType,
|
||||||
|
KeyID: []byte("key-id"),
|
||||||
|
VerifiedAt: time.Now().Add(-2 * time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Email: domain.HumanEmail{
|
||||||
|
Address: "[email protected]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
State: domain.UserStateActive,
|
||||||
|
}, nil)
|
||||||
|
return repo
|
||||||
|
}
|
||||||
@@ -155,6 +155,23 @@ func (w *Config) BeginLogin(ctx context.Context, user *domain.Human, userVerific
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *Config) BeginWebAuthNLogin(ctx context.Context, user webauthn.User, rpID string, userVerification protocol.UserVerificationRequirement) (sessionData *webauthn.SessionData, cred []byte, relyingPartyID string, err error) {
|
||||||
|
webAuthNServer, err := w.serverFromContext(ctx, rpID, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", err
|
||||||
|
}
|
||||||
|
assertion, sessionData, err := webAuthNServer.BeginLogin(user, webauthn.WithUserVerification(userVerification))
|
||||||
|
if err != nil {
|
||||||
|
logging.WithFields("error", tryExtractProtocolErrMsg(err)).Debug("webauthn login could not be started")
|
||||||
|
return nil, nil, "", zerrors.ThrowInternal(err, "WEBAU-4G8sw", "Errors.User.WebAuthN.BeginLoginFailed")
|
||||||
|
}
|
||||||
|
cred, err = json.Marshal(assertion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, "", zerrors.ThrowInternal(err, "WEBAU-2M0s9", "Errors.User.WebAuthN.MarshalError")
|
||||||
|
}
|
||||||
|
return sessionData, cred, webAuthNServer.Config.RPID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN *domain.WebAuthNLogin, credData []byte, webAuthNs ...*domain.WebAuthNToken) (*webauthn.Credential, error) {
|
func (w *Config) FinishLogin(ctx context.Context, user *domain.Human, webAuthN *domain.WebAuthNLogin, credData []byte, webAuthNs ...*domain.WebAuthNToken) (*webauthn.Credential, error) {
|
||||||
assertionData, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(credData))
|
assertionData, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(credData))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user