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:
Gayathri Vijayan
2026-04-01 12:13:40 +02:00
committed by GitHub
co-authored by Copilot Marco A.
parent 701bca6003
commit c7f92a6887
5 changed files with 1343 additions and 0 deletions
@@ -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
}
+17
View File
@@ -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 {