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
|
||||
}
|
||||
|
||||
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) {
|
||||
assertionData, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(credData))
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user