diff --git a/backend/v3/api/session/v2/convert/session_challenge_passkey.go b/backend/v3/api/session/v2/convert/session_challenge_passkey.go new file mode 100644 index 000000000..133c68c86 --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_challenge_passkey.go @@ -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 + } +} diff --git a/backend/v3/api/session/v2/convert/session_challenge_passkey_test.go b/backend/v3/api/session/v2/convert/session_challenge_passkey_test.go new file mode 100644 index 000000000..82e49a949 --- /dev/null +++ b/backend/v3/api/session/v2/convert/session_challenge_passkey_test.go @@ -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)) + }) + } +} diff --git a/backend/v3/domain/session_challenge_passkey.go b/backend/v3/domain/session_challenge_passkey.go new file mode 100644 index 000000000..914fb4e96 --- /dev/null +++ b/backend/v3/domain/session_challenge_passkey.go @@ -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 +} diff --git a/backend/v3/domain/session_challenge_passkey_test.go b/backend/v3/domain/session_challenge_passkey_test.go new file mode 100644 index 000000000..0a043571f --- /dev/null +++ b/backend/v3/domain/session_challenge_passkey_test.go @@ -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: "user@example.com", + }, + }, + State: domain.UserStateActive, + }, nil) + return repo +} diff --git a/internal/webauthn/webauthn.go b/internal/webauthn/webauthn.go index b41dc8ab8..2a54846e4 100644 --- a/internal/webauthn/webauthn.go +++ b/internal/webauthn/webauthn.go @@ -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 {