feat: session v2 passkey authentication (#5952)

This commit is contained in:
Tim Möhlmann 2023-06-07 17:28:42 +02:00 committed by GitHub
parent f7157b65f4
commit f456168a74
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 1261 additions and 162 deletions

View File

@ -43,7 +43,7 @@ jobs:
go run main.go init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml
go run main.go setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml
- name: Run integration tests
run: go test -tags=integration -race -parallel 1 -v -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./internal/integration ./internal/api/grpc/...
run: go test -tags=integration -race -p 1 -v -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./internal/integration ./internal/api/grpc/...
- name: Publish go coverage
uses: codecov/codecov-action@v3.1.0
with:

View File

@ -208,7 +208,7 @@ export INTEGRATION_DB_FLAVOR="cockroach" ZITADEL_MASTERKEY="MasterkeyNeedsToHave
docker compose -f internal/integration/config/docker-compose.yaml up --wait ${INTEGRATION_DB_FLAVOR}
go run main.go init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml
go run main.go setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml
go test -tags=integration -race -parallel 1 ./internal/integration ./internal/api/grpc/...
go test -count 1 -tags=integration -race -p 1 ./internal/integration ./internal/api/grpc/...
docker compose -f internal/integration/config/docker-compose.yaml down
```

View File

@ -30,7 +30,6 @@ func TestMain(m *testing.M) {
}
func TestServer_Healthz(t *testing.T) {
client := admin.NewAdminServiceClient(Tester.GRPCClientConn)
_, err := client.Healthz(context.TODO(), &admin.HealthzRequest{})
_, err := Tester.Client.Admin.Healthz(context.TODO(), &admin.HealthzRequest{})
require.NoError(t, err)
}

View File

@ -30,7 +30,9 @@ func AllFieldsSet(t testing.TB, msg protoreflect.Message, ignoreTypes ...protore
}
if fd.Kind() == protoreflect.MessageKind {
AllFieldsSet(t, msg.Get(fd).Message(), ignoreTypes...)
if m, ok := msg.Get(fd).Interface().(protoreflect.Message); ok {
AllFieldsSet(t, m, ignoreTypes...)
}
}
}
}

View File

@ -3,11 +3,13 @@ package session
import (
"context"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
@ -43,7 +45,9 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe
if err != nil {
return nil, err
}
set, err := s.command.CreateSession(ctx, checks, metadata)
challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks)
set, err := s.command.CreateSession(ctx, cmds, metadata)
if err != nil {
return nil, err
}
@ -51,6 +55,7 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe
Details: object.DomainToDetailsPb(set.ObjectDetails),
SessionId: set.ID,
SessionToken: set.NewToken,
Challenges: challengeResponse,
}, nil
}
@ -59,7 +64,9 @@ func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest)
if err != nil {
return nil, err
}
set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), checks, req.GetMetadata())
challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks)
set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), cmds, req.GetMetadata())
if err != nil {
return nil, err
}
@ -70,6 +77,7 @@ func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest)
return &session.SetSessionResponse{
Details: object.DomainToDetailsPb(set.ObjectDetails),
SessionToken: set.NewToken,
Challenges: challengeResponse,
}, nil
}
@ -104,13 +112,13 @@ func sessionToPb(s *query.Session) *session.Session {
func factorsToPb(s *query.Session) *session.Factors {
user := userFactorToPb(s.UserFactor)
pw := passwordFactorToPb(s.PasswordFactor)
if user == nil && pw == nil {
if user == nil {
return nil
}
return &session.Factors{
User: user,
Password: pw,
Password: passwordFactorToPb(s.PasswordFactor),
Passkey: passkeyFactorToPb(s.PasskeyFactor),
}
}
@ -123,6 +131,15 @@ func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFac
}
}
func passkeyFactorToPb(factor query.SessionPasskeyFactor) *session.PasskeyFactor {
if factor.PasskeyCheckedAt.IsZero() {
return nil
}
return &session.PasskeyFactor{
VerifiedAt: timestamppb.New(factor.PasskeyCheckedAt),
}
}
func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
return nil
@ -180,7 +197,7 @@ func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) {
return query.NewSessionIDsSearchQuery(q.Ids)
}
func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCheck, map[string][]byte, error) {
func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, error) {
checks, err := s.checksToCommand(ctx, req.Checks)
if err != nil {
return nil, nil, err
@ -188,7 +205,7 @@ func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session
return checks, req.GetMetadata(), nil
}
func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCheck, error) {
func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCommand, error) {
checks, err := s.checksToCommand(ctx, req.Checks)
if err != nil {
return nil, err
@ -196,12 +213,12 @@ func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.Se
return checks, nil
}
func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([]command.SessionCheck, error) {
func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([]command.SessionCommand, error) {
checkUser, err := userCheck(checks.GetUser())
if err != nil {
return nil, err
}
sessionChecks := make([]command.SessionCheck, 0, 2)
sessionChecks := make([]command.SessionCommand, 0, 3)
if checkUser != nil {
user, err := checkUser.search(ctx, s.query)
if err != nil {
@ -212,9 +229,38 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
if password := checks.GetPassword(); password != nil {
sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword()))
}
if passkey := checks.GetPasskey(); passkey != nil {
sessionChecks = append(sessionChecks, s.command.CheckPasskey(passkey.GetCredentialAssertionData()))
}
return sessionChecks, nil
}
func (s *Server) challengesToCommand(challenges []session.ChallengeKind, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand) {
if len(challenges) == 0 {
return nil, cmds
}
resp := new(session.Challenges)
for _, c := range challenges {
switch c {
case session.ChallengeKind_CHALLENGE_KIND_UNSPECIFIED:
continue
case session.ChallengeKind_CHALLENGE_KIND_PASSKEY:
passkeyChallenge, cmd := s.createPasskeyChallengeCommand()
resp.Passkey = passkeyChallenge
cmds = append(cmds, cmd)
}
}
return resp, cmds
}
func (s *Server) createPasskeyChallengeCommand() (*session.Challenges_Passkey, command.SessionCommand) {
challenge := &session.Challenges_Passkey{
PublicKeyCredentialRequestOptions: new(structpb.Struct),
}
return challenge, s.command.CreatePasskeyChallenge(domain.UserVerificationRequirementRequired, challenge.PublicKeyCredentialRequestOptions)
}
func userCheck(user *session.CheckUser) (userSearch, error) {
if user == nil {
return nil, nil

View File

@ -0,0 +1,270 @@
//go:build integration
package session_test
import (
"context"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var (
CTX context.Context
Tester *integration.Tester
Client session.SessionServiceClient
User *user.AddHumanUserResponse
)
func TestMain(m *testing.M) {
os.Exit(func() int {
ctx, errCtx, cancel := integration.Contexts(time.Hour)
defer cancel()
Tester = integration.NewTester(ctx)
defer Tester.Done()
Client = Tester.Client.SessionV2
CTX, _ = Tester.WithSystemAuthorization(ctx, integration.OrgOwner), errCtx
User = Tester.CreateHumanUser(CTX)
Tester.RegisterUserPasskey(CTX, User.GetUserId())
return m.Run()
}())
}
func verifyCurrentSession(t testing.TB, id, token string, sequence uint64, window time.Duration, metadata map[string][]byte, factors ...wantFactor) (s *session.Session) {
require.NotEmpty(t, id)
require.NotEmpty(t, token)
retry:
for {
resp, err := Client.GetSession(CTX, &session.GetSessionRequest{
SessionId: id,
SessionToken: &token,
})
if err == nil {
s = resp.GetSession()
break retry
}
if status.Convert(err).Code() == codes.NotFound {
select {
case <-CTX.Done():
t.Fatal(CTX.Err(), err)
case <-time.After(time.Second):
t.Log("retrying GetSession")
continue
}
}
require.NoError(t, err)
}
assert.Equal(t, id, s.GetId())
assert.WithinRange(t, s.GetCreationDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
assert.WithinRange(t, s.GetChangeDate().AsTime(), time.Now().Add(-window), time.Now().Add(window))
assert.Equal(t, sequence, s.GetSequence())
assert.Equal(t, metadata, s.GetMetadata())
verifyFactors(t, s.GetFactors(), window, factors)
return s
}
type wantFactor int
const (
wantUserFactor wantFactor = iota
wantPasswordFactor
wantPasskeyFactor
)
func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, want []wantFactor) {
for _, w := range want {
switch w {
case wantUserFactor:
uf := factors.GetUser()
assert.NotNil(t, uf)
assert.WithinRange(t, uf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
assert.Equal(t, User.GetUserId(), uf.GetId())
case wantPasswordFactor:
pf := factors.GetPassword()
assert.NotNil(t, pf)
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
case wantPasskeyFactor:
pf := factors.GetPasskey()
assert.NotNil(t, pf)
assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window))
}
}
}
func TestServer_CreateSession(t *testing.T) {
tests := []struct {
name string
req *session.CreateSessionRequest
want *session.CreateSessionResponse
wantErr bool
wantFactors []wantFactor
}{
{
name: "empty session",
req: &session.CreateSessionRequest{
Metadata: map[string][]byte{"foo": []byte("bar")},
},
want: &session.CreateSessionResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
},
{
name: "with user",
req: &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: User.GetUserId(),
},
},
},
Metadata: map[string][]byte{"foo": []byte("bar")},
},
want: &session.CreateSessionResponse{
Details: &object.Details{
ResourceOwner: Tester.Organisation.ID,
},
},
wantFactors: []wantFactor{wantUserFactor},
},
{
name: "password without user error",
req: &session.CreateSessionRequest{
Checks: &session.Checks{
Password: &session.CheckPassword{
Password: "Difficult",
},
},
},
wantErr: true,
},
{
name: "passkey without user error",
req: &session.CreateSessionRequest{
Challenges: []session.ChallengeKind{
session.ChallengeKind_CHALLENGE_KIND_PASSKEY,
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Client.CreateSession(CTX, tt.req)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
integration.AssertDetails(t, tt.want, got)
verifyCurrentSession(t, got.GetSessionId(), got.GetSessionToken(), got.GetDetails().GetSequence(), time.Minute, tt.req.GetMetadata(), tt.wantFactors...)
})
}
}
func TestServer_CreateSession_passkey(t *testing.T) {
// create new session with user and request the passkey challenge
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: User.GetUserId(),
},
},
},
Challenges: []session.ChallengeKind{
session.ChallengeKind_CHALLENGE_KIND_PASSKEY,
},
})
require.NoError(t, err)
verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil)
assertionData, err := Tester.WebAuthN.CreateAssertionResponse(createResp.GetChallenges().GetPasskey().GetPublicKeyCredentialRequestOptions())
require.NoError(t, err)
// update the session with passkey assertion data
updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{
SessionId: createResp.GetSessionId(),
SessionToken: createResp.GetSessionToken(),
Checks: &session.Checks{
Passkey: &session.CheckPasskey{
CredentialAssertionData: assertionData,
},
},
})
require.NoError(t, err)
verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantPasskeyFactor)
}
func TestServer_SetSession_flow(t *testing.T) {
var wantFactors []wantFactor
// create new, empty session
createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{})
require.NoError(t, err)
verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...)
sessionToken := createResp.GetSessionToken()
t.Run("check user", func(t *testing.T) {
wantFactors = append(wantFactors, wantUserFactor)
resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
SessionId: createResp.GetSessionId(),
SessionToken: sessionToken,
Checks: &session.Checks{
User: &session.CheckUser{
Search: &session.CheckUser_UserId{
UserId: User.GetUserId(),
},
},
},
})
require.NoError(t, err)
verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...)
sessionToken = resp.GetSessionToken()
})
t.Run("check passkey", func(t *testing.T) {
resp, err := Client.SetSession(CTX, &session.SetSessionRequest{
SessionId: createResp.GetSessionId(),
SessionToken: sessionToken,
Challenges: []session.ChallengeKind{
session.ChallengeKind_CHALLENGE_KIND_PASSKEY,
},
})
require.NoError(t, err)
verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...)
sessionToken = resp.GetSessionToken()
wantFactors = append(wantFactors, wantPasskeyFactor)
assertionData, err := Tester.WebAuthN.CreateAssertionResponse(resp.GetChallenges().GetPasskey().GetPublicKeyCredentialRequestOptions())
require.NoError(t, err)
resp, err = Client.SetSession(CTX, &session.SetSessionRequest{
SessionId: createResp.GetSessionId(),
SessionToken: sessionToken,
Checks: &session.Checks{
Passkey: &session.CheckPasskey{
CredentialAssertionData: assertionData,
},
},
})
require.NoError(t, err)
verifyCurrentSession(t, createResp.GetSessionId(), resp.GetSessionToken(), resp.GetDetails().GetSequence(), time.Minute, nil, wantFactors...)
})
}

View File

@ -49,7 +49,7 @@ func Test_sessionsToPb(t *testing.T) {
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // no factor
{ // password factor
ID: "999",
CreationDate: now,
ChangeDate: now,
@ -57,11 +57,36 @@ func Test_sessionsToPb(t *testing.T) {
State: domain.SessionStateActive,
ResourceOwner: "me",
Creator: "he",
UserFactor: query.SessionUserFactor{
UserID: "345",
UserCheckedAt: past,
LoginName: "donald",
DisplayName: "donald duck",
},
PasswordFactor: query.SessionPasswordFactor{
PasswordCheckedAt: past,
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // passkey factor
ID: "999",
CreationDate: now,
ChangeDate: now,
Sequence: 123,
State: domain.SessionStateActive,
ResourceOwner: "me",
Creator: "he",
UserFactor: query.SessionUserFactor{
UserID: "345",
UserCheckedAt: past,
LoginName: "donald",
DisplayName: "donald duck",
},
PasskeyFactor: query.SessionPasskeyFactor{
PasskeyCheckedAt: past,
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
}
want := []*session.Session{
@ -94,12 +119,36 @@ func Test_sessionsToPb(t *testing.T) {
ChangeDate: timestamppb.New(now),
Sequence: 123,
Factors: &session.Factors{
User: &session.UserFactor{
VerifiedAt: timestamppb.New(past),
Id: "345",
LoginName: "donald",
DisplayName: "donald duck",
},
Password: &session.PasswordFactor{
VerifiedAt: timestamppb.New(past),
},
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
{ // passkey factor
Id: "999",
CreationDate: timestamppb.New(now),
ChangeDate: timestamppb.New(now),
Sequence: 123,
Factors: &session.Factors{
User: &session.UserFactor{
VerifiedAt: timestamppb.New(past),
Id: "345",
LoginName: "donald",
DisplayName: "donald duck",
},
Passkey: &session.PasskeyFactor{
VerifiedAt: timestamppb.New(past),
},
},
Metadata: map[string][]byte{"hello": []byte("world")},
},
}
out := sessionsToPb(sessions)
@ -107,7 +156,7 @@ func Test_sessionsToPb(t *testing.T) {
for i, got := range out {
if !proto.Equal(got, want[i]) {
t.Errorf("session %d got:\n%v\nwant:\n%v", i, got, want)
t.Errorf("session %d got:\n%v\nwant:\n%v", i, got, want[i])
}
}
}

View File

@ -3,9 +3,7 @@
package user_test
import (
"fmt"
"testing"
"time"
"github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert"
@ -16,31 +14,8 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
func createHumanUser(t *testing.T) *user.AddHumanUserResponse {
resp, err := Client.AddHumanUser(CTX, &user.AddHumanUserRequest{
Organisation: &object.Organisation{
Org: &object.Organisation_OrgId{
OrgId: Tester.Organisation.ID,
},
},
Profile: &user.SetHumanProfile{
FirstName: "Mickey",
LastName: "Mouse",
},
Email: &user.SetHumanEmail{
Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()),
Verification: &user.SetHumanEmail_ReturnCode{
ReturnCode: &user.ReturnEmailVerificationCode{},
},
},
})
require.NoError(t, err)
require.NotEmpty(t, resp.GetUserId())
return resp
}
func TestServer_SetEmail(t *testing.T) {
userID := createHumanUser(t).GetUserId()
userID := Tester.CreateHumanUser(CTX).GetUserId()
tests := []struct {
name string
@ -158,7 +133,7 @@ func TestServer_SetEmail(t *testing.T) {
}
func TestServer_VerifyEmail(t *testing.T) {
userResp := createHumanUser(t)
userResp := Tester.CreateHumanUser(CTX)
tests := []struct {
name string
req *user.VerifyEmailRequest

View File

@ -3,6 +3,9 @@ package user
import (
"context"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain"
@ -42,16 +45,24 @@ func passkeyRegistrationDetailsToPb(details *domain.PasskeyRegistrationDetails,
if err != nil {
return nil, err
}
options := new(structpb.Struct)
if err := protojson.Unmarshal(details.PublicKeyCredentialCreationOptions, options); err != nil {
return nil, caos_errs.ThrowInternal(err, "USERv2-Dohr6", "Errors.Internal")
}
return &user.RegisterPasskeyResponse{
Details: object.DomainToDetailsPb(details.ObjectDetails),
PasskeyId: details.PasskeyID,
PublicKeyCredentialCreationOptions: details.PublicKeyCredentialCreationOptions,
PublicKeyCredentialCreationOptions: options,
}, nil
}
func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) {
resourceOwner := authz.GetCtxData(ctx).ResourceOwner
objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), resourceOwner, req.GetPasskeyName(), "", req.GetPublicKeyCredential())
pkc, err := protojson.Marshal(req.GetPublicKeyCredential())
if err != nil {
return nil, caos_errs.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal")
}
objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), resourceOwner, req.GetPasskeyName(), "", pkc)
if err != nil {
return nil, err
}

View File

@ -10,19 +10,18 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/integration"
"github.com/zitadel/zitadel/internal/webauthn"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
"google.golang.org/protobuf/types/known/structpb"
)
func TestServer_RegisterPasskey(t *testing.T) {
userID := createHumanUser(t).GetUserId()
userID := Tester.CreateHumanUser(CTX).GetUserId()
reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
})
require.NoError(t, err)
client := webauthn.NewClient(Tester.Config.WebAuthNName, Tester.Config.ExternalDomain, "https://"+Tester.Host())
type args struct {
ctx context.Context
@ -125,7 +124,7 @@ func TestServer_RegisterPasskey(t *testing.T) {
if tt.want != nil {
assert.NotEmpty(t, got.GetPasskeyId())
assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions())
_, err := client.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions())
_, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions())
require.NoError(t, err)
}
})
@ -133,7 +132,7 @@ func TestServer_RegisterPasskey(t *testing.T) {
}
func TestServer_VerifyPasskeyRegistration(t *testing.T) {
userID := createHumanUser(t).GetUserId()
userID := Tester.CreateHumanUser(CTX).GetUserId()
reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
@ -147,8 +146,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) {
require.NotEmpty(t, pkr.GetPasskeyId())
require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions())
client := webauthn.NewClient(Tester.Config.WebAuthNName, Tester.Config.ExternalDomain, "https://"+Tester.Host())
attestationResponse, err := client.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
require.NoError(t, err)
type args struct {
@ -167,7 +165,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) {
ctx: CTX,
req: &user.VerifyPasskeyRegistrationRequest{
PasskeyId: pkr.GetPasskeyId(),
PublicKeyCredential: []byte(attestationResponse),
PublicKeyCredential: attestationResponse,
PasskeyName: "nice name",
},
},
@ -195,10 +193,12 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) {
args: args{
ctx: CTX,
req: &user.VerifyPasskeyRegistrationRequest{
UserId: userID,
PasskeyId: pkr.GetPasskeyId(),
PublicKeyCredential: []byte("attestationResponseattestationResponseattestationResponse"),
PasskeyName: "nice name",
UserId: userID,
PasskeyId: pkr.GetPasskeyId(),
PublicKeyCredential: &structpb.Struct{
Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
},
PasskeyName: "nice name",
},
},
wantErr: true,
@ -219,7 +219,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) {
}
func TestServer_CreatePasskeyRegistrationLink(t *testing.T) {
userID := createHumanUser(t).GetUserId()
userID := Tester.CreateHumanUser(CTX).GetUserId()
type args struct {
ctx context.Context

View File

@ -7,10 +7,13 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
@ -51,9 +54,10 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
err error
}
tests := []struct {
name string
args args
want *user.RegisterPasskeyResponse
name string
args args
want *user.RegisterPasskeyResponse
wantErr error
}{
{
name: "an error",
@ -61,6 +65,23 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
details: nil,
err: io.ErrClosedPipe,
},
wantErr: io.ErrClosedPipe,
},
{
name: "unmarshall error",
args: args{
details: &domain.PasskeyRegistrationDetails{
ObjectDetails: &domain.ObjectDetails{
Sequence: 22,
EventDate: time.Unix(3000, 22),
ResourceOwner: "me",
},
PasskeyID: "123",
PublicKeyCredentialCreationOptions: []byte(`\\`),
},
err: nil,
},
wantErr: caos_errs.ThrowInternal(nil, "USERv2-Dohr6", "Errors.Internal"),
},
{
name: "ok",
@ -72,7 +93,7 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
ResourceOwner: "me",
},
PasskeyID: "123",
PublicKeyCredentialCreationOptions: []byte{1, 2, 3},
PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`),
},
err: nil,
},
@ -85,16 +106,20 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
},
ResourceOwner: "me",
},
PasskeyId: "123",
PublicKeyCredentialCreationOptions: []byte{1, 2, 3},
PasskeyId: "123",
PublicKeyCredentialCreationOptions: &structpb.Struct{
Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err)
require.ErrorIs(t, err, tt.args.err)
assert.Equal(t, tt.want, got)
require.ErrorIs(t, err, tt.wantErr)
if !proto.Equal(tt.want, got) {
t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got)
}
if tt.want != nil {
grpc.AllFieldsSet(t, got.ProtoReflect())
}

View File

@ -42,7 +42,7 @@ func TestMain(m *testing.M) {
defer Tester.Done()
CTX, ErrCTX = Tester.WithSystemAuthorization(ctx, integration.OrgOwner), errCtx
Client = user.NewUserServiceClient(Tester.GRPCClientConn)
Client = Tester.Client.UserV2
return m.Run()
}())
}

View File

@ -16,10 +16,10 @@ import (
"github.com/zitadel/zitadel/internal/telemetry/tracing"
)
type SessionCheck func(ctx context.Context, cmd *SessionChecks) error
type SessionCommand func(ctx context.Context, cmd *SessionCommands) error
type SessionChecks struct {
checks []SessionCheck
type SessionCommands struct {
cmds []SessionCommand
sessionWriteModel *SessionWriteModel
passwordWriteModel *HumanPasswordWriteModel
@ -29,9 +29,9 @@ type SessionChecks struct {
now func() time.Time
}
func (c *Commands) NewSessionChecks(checks []SessionCheck, session *SessionWriteModel) *SessionChecks {
return &SessionChecks{
checks: checks,
func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWriteModel) *SessionCommands {
return &SessionCommands{
cmds: cmds,
sessionWriteModel: session,
eventstore: c.eventstore,
userPasswordAlg: c.userPasswordAlg,
@ -41,8 +41,8 @@ func (c *Commands) NewSessionChecks(checks []SessionCheck, session *SessionWrite
}
// CheckUser defines a user check to be executed for a session update
func CheckUser(id string) SessionCheck {
return func(ctx context.Context, cmd *SessionChecks) error {
func CheckUser(id string) SessionCommand {
return func(ctx context.Context, cmd *SessionCommands) error {
if cmd.sessionWriteModel.UserID != "" && id != "" && cmd.sessionWriteModel.UserID != id {
return caos_errs.ThrowInvalidArgument(nil, "", "user change not possible")
}
@ -51,8 +51,8 @@ func CheckUser(id string) SessionCheck {
}
// CheckPassword defines a password check to be executed for a session update
func CheckPassword(password string) SessionCheck {
return func(ctx context.Context, cmd *SessionChecks) error {
func CheckPassword(password string) SessionCommand {
return func(ctx context.Context, cmd *SessionCommands) error {
if cmd.sessionWriteModel.UserID == "" {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfw3f", "Errors.User.UserIDMissing")
}
@ -80,17 +80,32 @@ func CheckPassword(password string) SessionCheck {
}
}
// Check will execute the checks specified and return an error on the first occurrence
func (s *SessionChecks) Check(ctx context.Context) error {
for _, check := range s.checks {
if err := check(ctx, s); err != nil {
// Exec will execute the commands specified and returns an error on the first occurrence
func (s *SessionCommands) Exec(ctx context.Context) error {
for _, cmd := range s.cmds {
if err := cmd(ctx, s); err != nil {
return err
}
}
return nil
}
func (s *SessionChecks) commands(ctx context.Context) (string, []eventstore.Command, error) {
func (s *SessionCommands) gethumanWriteModel(ctx context.Context) (*HumanWriteModel, error) {
if s.sessionWriteModel.UserID == "" {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eeR2e", "Errors.User.UserIDMissing")
}
humanWriteModel := NewHumanWriteModel(s.sessionWriteModel.UserID, s.sessionWriteModel.ResourceOwner)
err := s.eventstore.FilterToQueryReducer(ctx, humanWriteModel)
if err != nil {
return nil, err
}
if humanWriteModel.UserState != domain.UserStateActive {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4b3", "Errors.ie4Ai.NotFound")
}
return humanWriteModel, nil
}
func (s *SessionCommands) commands(ctx context.Context) (string, []eventstore.Command, error) {
if len(s.sessionWriteModel.commands) == 0 {
return "", nil, nil
}
@ -103,7 +118,7 @@ func (s *SessionChecks) commands(ctx context.Context) (string, []eventstore.Comm
return token, s.sessionWriteModel.commands, nil
}
func (c *Commands) CreateSession(ctx context.Context, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) {
func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, metadata map[string][]byte) (set *SessionChanged, err error) {
sessionID, err := c.idGenerator.Next()
if err != nil {
return nil, err
@ -113,12 +128,12 @@ func (c *Commands) CreateSession(ctx context.Context, checks []SessionCheck, met
if err != nil {
return nil, err
}
cmd := c.NewSessionChecks(checks, sessionWriteModel)
cmd := c.NewSessionCommands(cmds, sessionWriteModel)
cmd.sessionWriteModel.Start(ctx)
return c.updateSession(ctx, cmd, metadata)
}
func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken string, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) {
func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken string, cmds []SessionCommand, metadata map[string][]byte) (set *SessionChanged, err error) {
sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID)
err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel)
if err != nil {
@ -127,7 +142,7 @@ func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken st
if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionWrite); err != nil {
return nil, err
}
cmd := c.NewSessionChecks(checks, sessionWriteModel)
cmd := c.NewSessionCommands(cmds, sessionWriteModel)
return c.updateSession(ctx, cmd, metadata)
}
@ -154,12 +169,12 @@ func (c *Commands) TerminateSession(ctx context.Context, sessionID, sessionToken
return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil
}
// updateSession execute the [SessionChecks] where new events will be created and as well as for metadata (changes)
func (c *Commands) updateSession(ctx context.Context, checks *SessionChecks, metadata map[string][]byte) (set *SessionChanged, err error) {
// updateSession execute the [SessionCommands] where new events will be created and as well as for metadata (changes)
func (c *Commands) updateSession(ctx context.Context, checks *SessionCommands, metadata map[string][]byte) (set *SessionChanged, err error) {
if checks.sessionWriteModel.State == domain.SessionStateTerminated {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated")
}
if err := checks.Check(ctx); err != nil {
if err := checks.Exec(ctx); err != nil {
// TODO: how to handle failed checks (e.g. pw wrong) https://github.com/zitadel/zitadel/issues/5807
return nil, err
}

View File

@ -6,10 +6,31 @@ import (
"time"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/repository/session"
usr_repo "github.com/zitadel/zitadel/internal/repository/user"
)
type PasskeyChallengeModel struct {
Challenge string
AllowedCrentialIDs [][]byte
UserVerification domain.UserVerificationRequirement
}
func (p *PasskeyChallengeModel) WebAuthNLogin(human *domain.Human, credentialAssertionData []byte) (*domain.WebAuthNLogin, error) {
if p == nil {
return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Ioqu5", "Errors.Session.Passkey.NoChallenge")
}
return &domain.WebAuthNLogin{
ObjectRoot: human.ObjectRoot,
CredentialAssertionData: credentialAssertionData,
Challenge: p.Challenge,
AllowedCredentialIDs: p.AllowedCrentialIDs,
UserVerification: p.UserVerification,
}, nil
}
type SessionWriteModel struct {
eventstore.WriteModel
@ -17,9 +38,12 @@ type SessionWriteModel struct {
UserID string
UserCheckedAt time.Time
PasswordCheckedAt time.Time
PasskeyCheckedAt time.Time
Metadata map[string][]byte
State domain.SessionState
PasskeyChallenge *PasskeyChallengeModel
commands []eventstore.Command
aggregate *eventstore.Aggregate
}
@ -44,6 +68,10 @@ func (wm *SessionWriteModel) Reduce() error {
wm.reduceUserChecked(e)
case *session.PasswordCheckedEvent:
wm.reducePasswordChecked(e)
case *session.PasskeyChallengedEvent:
wm.reducePasskeyChallenged(e)
case *session.PasskeyCheckedEvent:
wm.reducePasskeyChecked(e)
case *session.TokenSetEvent:
wm.reduceTokenSet(e)
case *session.TerminateEvent:
@ -62,6 +90,8 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder {
session.AddedType,
session.UserCheckedType,
session.PasswordCheckedType,
session.PasskeyChallengedType,
session.PasskeyCheckedType,
session.TokenSetType,
session.MetadataSetType,
session.TerminateType,
@ -87,6 +117,19 @@ func (wm *SessionWriteModel) reducePasswordChecked(e *session.PasswordCheckedEve
wm.PasswordCheckedAt = e.CheckedAt
}
func (wm *SessionWriteModel) reducePasskeyChallenged(e *session.PasskeyChallengedEvent) {
wm.PasskeyChallenge = &PasskeyChallengeModel{
Challenge: e.Challenge,
AllowedCrentialIDs: e.AllowedCrentialIDs,
UserVerification: e.UserVerification,
}
}
func (wm *SessionWriteModel) reducePasskeyChecked(e *session.PasskeyCheckedEvent) {
wm.PasskeyChallenge = nil
wm.PasskeyCheckedAt = e.CheckedAt
}
func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) {
wm.TokenID = e.TokenID
}
@ -110,6 +153,17 @@ func (wm *SessionWriteModel) PasswordChecked(ctx context.Context, checkedAt time
wm.commands = append(wm.commands, session.NewPasswordCheckedEvent(ctx, wm.aggregate, checkedAt))
}
func (wm *SessionWriteModel) PasskeyChallenged(ctx context.Context, challenge string, allowedCrentialIDs [][]byte, userVerification domain.UserVerificationRequirement) {
wm.commands = append(wm.commands, session.NewPasskeyChallengedEvent(ctx, wm.aggregate, challenge, allowedCrentialIDs, userVerification))
}
func (wm *SessionWriteModel) PasskeyChecked(ctx context.Context, checkedAt time.Time, tokenID string, signCount uint32) {
wm.commands = append(wm.commands,
session.NewPasskeyCheckedEvent(ctx, wm.aggregate, checkedAt),
usr_repo.NewHumanPasswordlessSignCountChangedEvent(ctx, wm.aggregate, tokenID, signCount),
)
}
func (wm *SessionWriteModel) SetToken(ctx context.Context, tokenID string) {
wm.commands = append(wm.commands, session.NewTokenSetEvent(ctx, wm.aggregate, tokenID))
}

View File

@ -0,0 +1,84 @@
package command
import (
"context"
"encoding/json"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
)
type humanPasskeys struct {
human *domain.Human
tokens []*domain.WebAuthNToken
}
func (s *SessionCommands) getHumanPasskeys(ctx context.Context) (*humanPasskeys, error) {
humanWritemodel, err := s.gethumanWriteModel(ctx)
if err != nil {
return nil, err
}
tokenReadModel, err := s.getHumanPasswordlessTokenReadModel(ctx)
if err != nil {
return nil, err
}
return &humanPasskeys{
human: writeModelToHuman(humanWritemodel),
tokens: readModelToPasswordlessTokens(tokenReadModel),
}, nil
}
func (s *SessionCommands) getHumanPasswordlessTokenReadModel(ctx context.Context) (*HumanPasswordlessTokensReadModel, error) {
tokenReadModel := NewHumanPasswordlessTokensReadModel(s.sessionWriteModel.UserID, s.sessionWriteModel.ResourceOwner)
err := s.eventstore.FilterToQueryReducer(ctx, tokenReadModel)
if err != nil {
return nil, err
}
return tokenReadModel, nil
}
func (c *Commands) CreatePasskeyChallenge(userVerification domain.UserVerificationRequirement, dst json.Unmarshaler) SessionCommand {
return func(ctx context.Context, cmd *SessionCommands) error {
humanPasskeys, err := cmd.getHumanPasskeys(ctx)
if err != nil {
return err
}
webAuthNLogin, err := c.webauthnConfig.BeginLogin(ctx, humanPasskeys.human, userVerification, humanPasskeys.tokens...)
if err != nil {
return err
}
if err = json.Unmarshal(webAuthNLogin.CredentialAssertionData, dst); err != nil {
return caos_errs.ThrowInternal(err, "COMMAND-Yah6A", "Errors.Internal")
}
cmd.sessionWriteModel.PasskeyChallenged(ctx, webAuthNLogin.Challenge, webAuthNLogin.AllowedCredentialIDs, webAuthNLogin.UserVerification)
return nil
}
}
func (c *Commands) CheckPasskey(credentialAssertionData json.Marshaler) SessionCommand {
return func(ctx context.Context, cmd *SessionCommands) error {
credentialAssertionData, err := json.Marshal(credentialAssertionData)
if err != nil {
return caos_errs.ThrowInvalidArgument(err, "COMMAND-ohG2o", "todo")
}
humanPasskeys, err := cmd.getHumanPasskeys(ctx)
if err != nil {
return err
}
webAuthN, err := cmd.sessionWriteModel.PasskeyChallenge.WebAuthNLogin(humanPasskeys.human, credentialAssertionData)
if err != nil {
return err
}
keyID, signCount, err := c.webauthnConfig.FinishLogin(ctx, humanPasskeys.human, webAuthN, credentialAssertionData, humanPasskeys.tokens...)
if err != nil && keyID == nil {
return err
}
_, token := domain.GetTokenByKeyID(humanPasskeys.tokens, keyID)
if token == nil {
return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Aej7i", "Errors.User.WebAuthN.NotFound")
}
cmd.sessionWriteModel.PasskeyChecked(ctx, cmd.now(), token.WebAuthNTokenID, signCount)
return nil
}
}

View File

@ -0,0 +1,130 @@
package command
import (
"context"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/repository/org"
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestSessionCommands_getHumanPasskeys(t *testing.T) {
userAggr := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
eventstore *eventstore.Eventstore
sessionWriteModel *SessionWriteModel
}
type res struct {
want *humanPasskeys
err error
}
tests := []struct {
name string
fields fields
res res
}{
{
name: "missing UID",
fields: fields{
eventstore: &eventstore.Eventstore{},
sessionWriteModel: &SessionWriteModel{},
},
res: res{
want: nil,
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eeR2e", "Errors.User.UserIDMissing"),
},
},
{
name: "passwordless filter error",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAggr,
"", "", "", "", "", language.Georgian,
domain.GenderDiverse, "", true,
),
),
),
expectFilterError(io.ErrClosedPipe),
),
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
},
},
res: res{
want: nil,
err: io.ErrClosedPipe,
},
},
{
name: "ok",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAggr,
"", "", "", "", "", language.Georgian,
domain.GenderDiverse, "", true,
),
),
),
expectFilter(eventFromEventPusher(
user.NewHumanWebAuthNAddedEvent(eventstore.NewBaseEventForPush(
context.Background(), &org.NewAggregate("org1").Aggregate, user.HumanPasswordlessTokenAddedType,
), "111", "challenge"),
)),
),
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
},
},
res: res{
want: &humanPasskeys{
human: &domain.Human{
ObjectRoot: models.ObjectRoot{
AggregateID: "user1",
ResourceOwner: "org1",
},
State: domain.UserStateActive,
Profile: &domain.Profile{
PreferredLanguage: language.Georgian,
Gender: domain.GenderDiverse,
},
Email: &domain.Email{},
},
tokens: []*domain.WebAuthNToken{{
ObjectRoot: models.ObjectRoot{
AggregateID: "org1",
},
WebAuthNTokenID: "111",
State: domain.MFAStateNotReady,
Challenge: "challenge",
}},
},
err: nil,
},
},
}
for _, tt := range tests {
s := &SessionCommands{
eventstore: tt.fields.eventstore,
sessionWriteModel: tt.fields.sessionWriteModel,
}
got, err := s.getHumanPasskeys(context.Background())
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
}
}

View File

@ -2,6 +2,7 @@ package command
import (
"context"
"io"
"testing"
"time"
@ -21,6 +22,121 @@ import (
"github.com/zitadel/zitadel/internal/repository/user"
)
func TestSessionCommands_getHumanWriteModel(t *testing.T) {
userAggr := &user.NewAggregate("user1", "org1").Aggregate
type fields struct {
eventstore *eventstore.Eventstore
sessionWriteModel *SessionWriteModel
}
type res struct {
want *HumanWriteModel
err error
}
tests := []struct {
name string
fields fields
res res
}{
{
name: "missing UID",
fields: fields{
eventstore: &eventstore.Eventstore{},
sessionWriteModel: &SessionWriteModel{},
},
res: res{
want: nil,
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eeR2e", "Errors.User.UserIDMissing"),
},
},
{
name: "filter error",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilterError(io.ErrClosedPipe),
),
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
},
},
res: res{
want: nil,
err: io.ErrClosedPipe,
},
},
{
name: "removed user",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAggr,
"", "", "", "", "", language.Georgian,
domain.GenderDiverse, "", true,
),
),
eventFromEventPusher(
user.NewUserRemovedEvent(context.Background(),
userAggr,
"", nil, true,
),
),
),
),
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
},
},
res: res{
want: nil,
err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4b3", "Errors.ie4Ai.NotFound"),
},
},
{
name: "ok",
fields: fields{
eventstore: eventstoreExpect(t,
expectFilter(
eventFromEventPusher(
user.NewHumanAddedEvent(context.Background(),
userAggr,
"", "", "", "", "", language.Georgian,
domain.GenderDiverse, "", true,
),
),
),
),
sessionWriteModel: &SessionWriteModel{
UserID: "user1",
},
},
res: res{
want: &HumanWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: "user1",
ResourceOwner: "org1",
Events: []eventstore.Event{},
},
PreferredLanguage: language.Georgian,
Gender: domain.GenderDiverse,
UserState: domain.UserStateActive,
},
err: nil,
},
},
}
for _, tt := range tests {
s := &SessionCommands{
eventstore: tt.fields.eventstore,
sessionWriteModel: tt.fields.sessionWriteModel,
}
got, err := s.gethumanWriteModel(context.Background())
require.ErrorIs(t, err, tt.res.err)
assert.Equal(t, tt.res.want, got)
}
}
func TestCommands_CreateSession(t *testing.T) {
type fields struct {
eventstore *eventstore.Eventstore
@ -29,7 +145,7 @@ func TestCommands_CreateSession(t *testing.T) {
}
type args struct {
ctx context.Context
checks []SessionCheck
checks []SessionCommand
metadata map[string][]byte
}
type res struct {
@ -126,7 +242,7 @@ func TestCommands_UpdateSession(t *testing.T) {
ctx context.Context
sessionID string
sessionToken string
checks []SessionCheck
checks []SessionCommand
metadata map[string][]byte
}
type res struct {
@ -231,7 +347,7 @@ func TestCommands_updateSession(t *testing.T) {
}
type args struct {
ctx context.Context
checks *SessionChecks
checks *SessionCommands
metadata map[string][]byte
}
type res struct {
@ -251,7 +367,7 @@ func TestCommands_updateSession(t *testing.T) {
},
args{
ctx: context.Background(),
checks: &SessionChecks{
checks: &SessionCommands{
sessionWriteModel: &SessionWriteModel{State: domain.SessionStateTerminated},
},
},
@ -266,10 +382,10 @@ func TestCommands_updateSession(t *testing.T) {
},
args{
ctx: context.Background(),
checks: &SessionChecks{
checks: &SessionCommands{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{
func(ctx context.Context, cmd *SessionChecks) error {
cmds: []SessionCommand{
func(ctx context.Context, cmd *SessionCommands) error {
return caos_errs.ThrowInternal(nil, "id", "check failed")
},
},
@ -286,9 +402,9 @@ func TestCommands_updateSession(t *testing.T) {
},
args{
ctx: context.Background(),
checks: &SessionChecks{
checks: &SessionCommands{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{},
cmds: []SessionCommand{},
},
},
res{
@ -321,9 +437,9 @@ func TestCommands_updateSession(t *testing.T) {
},
args{
ctx: context.Background(),
checks: &SessionChecks{
checks: &SessionCommands{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{
cmds: []SessionCommand{
CheckUser("userID"),
CheckPassword("password"),
},

View File

@ -35,7 +35,7 @@ func AssertDetails[D DetailsMsg](t testing.TB, exptected, actual D) {
gotCD := gotDetails.GetChangeDate().AsTime()
now := time.Now()
assert.WithinRange(t, gotCD, now.Add(-time.Second), now.Add(time.Second))
assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute))
assert.Equal(t, wantDetails.GetResourceOwner(), gotDetails.GetResourceOwner())
}

View File

@ -0,0 +1,77 @@
package integration
import (
"context"
"fmt"
"time"
"github.com/zitadel/logging"
"google.golang.org/grpc"
"github.com/zitadel/zitadel/pkg/grpc/admin"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
)
type Client struct {
CC *grpc.ClientConn
Admin admin.AdminServiceClient
UserV2 user.UserServiceClient
SessionV2 session.SessionServiceClient
}
func newClient(cc *grpc.ClientConn) Client {
return Client{
CC: cc,
Admin: admin.NewAdminServiceClient(cc),
UserV2: user.NewUserServiceClient(cc),
SessionV2: session.NewSessionServiceClient(cc),
}
}
func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse {
resp, err := s.Client.UserV2.AddHumanUser(ctx, &user.AddHumanUserRequest{
Organisation: &object.Organisation{
Org: &object.Organisation_OrgId{
OrgId: s.Organisation.ID,
},
},
Profile: &user.SetHumanProfile{
FirstName: "Mickey",
LastName: "Mouse",
},
Email: &user.SetHumanEmail{
Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()),
Verification: &user.SetHumanEmail_ReturnCode{
ReturnCode: &user.ReturnEmailVerificationCode{},
},
},
})
logging.OnError(err).Fatal("create human user")
return resp
}
func (s *Tester) RegisterUserPasskey(ctx context.Context, userID string) {
reg, err := s.Client.UserV2.CreatePasskeyRegistrationLink(ctx, &user.CreatePasskeyRegistrationLinkRequest{
UserId: userID,
Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{},
})
logging.OnError(err).Fatal("create user passkey")
pkr, err := s.Client.UserV2.RegisterPasskey(ctx, &user.RegisterPasskeyRequest{
UserId: userID,
Code: reg.GetCode(),
})
logging.OnError(err).Fatal("create user passkey")
attestationResponse, err := s.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions())
logging.OnError(err).Fatal("create user passkey")
_, err = s.Client.UserV2.VerifyPasskeyRegistration(ctx, &user.VerifyPasskeyRegistrationRequest{
UserId: userID,
PasskeyId: pkr.GetPasskeyId(),
PublicKeyCredential: attestationResponse,
PasskeyName: "nice name",
})
logging.OnError(err).Fatal("create user passkey")
}

View File

@ -29,6 +29,7 @@ import (
caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/webauthn"
"github.com/zitadel/zitadel/pkg/grpc/admin"
)
@ -68,8 +69,9 @@ type Tester struct {
Organisation *query.Org
Users map[UserType]User
GRPCClientConn *grpc.ClientConn
wg sync.WaitGroup // used for shutdown
Client Client
WebAuthN *webauthn.Client
wg sync.WaitGroup // used for shutdown
}
const commandLine = `start --masterkeyFromEnv`
@ -90,7 +92,7 @@ func (s *Tester) createClientConn(ctx context.Context) {
logging.OnError(err).Fatal("integration tester client dial")
logging.New().WithField("target", target).Info("finished dialing grpc client conn")
s.GRPCClientConn = cc
s.Client = newClient(cc)
err = s.pollHealth(ctx)
logging.OnError(err).Fatal("integration tester health")
}
@ -99,14 +101,12 @@ func (s *Tester) createClientConn(ctx context.Context) {
// TODO: remove when we make the setup blocking on all
// projections completed.
func (s *Tester) pollHealth(ctx context.Context) (err error) {
client := admin.NewAdminServiceClient(s.GRPCClientConn)
for {
err = func(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
_, err := client.Healthz(ctx, &admin.HealthzRequest{})
_, err := s.Client.Admin.Healthz(ctx, &admin.HealthzRequest{})
return err
}(ctx)
if err == nil {
@ -182,7 +182,7 @@ func (s *Tester) WithSystemAuthorization(ctx context.Context, u UserType) contex
// Done send an interrupt signal to cleanly shutdown the server.
func (s *Tester) Done() {
err := s.GRPCClientConn.Close()
err := s.Client.CC.Close()
logging.OnError(err).Error("integration tester client close")
s.Shutdown <- os.Interrupt
@ -238,6 +238,7 @@ func NewTester(ctx context.Context) *Tester {
}
tester.createClientConn(ctx)
tester.createSystemUser(ctx)
tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, "https://"+tester.Host())
return tester
}

View File

@ -13,7 +13,7 @@ import (
)
const (
SessionsProjectionTable = "projections.sessions"
SessionsProjectionTable = "projections.sessions1"
SessionColumnID = "id"
SessionColumnCreationDate = "creation_date"
@ -26,6 +26,7 @@ const (
SessionColumnUserID = "user_id"
SessionColumnUserCheckedAt = "user_checked_at"
SessionColumnPasswordCheckedAt = "password_checked_at"
SessionColumnPasskeyCheckedAt = "passkey_checked_at"
SessionColumnMetadata = "metadata"
SessionColumnTokenID = "token_id"
)
@ -51,6 +52,7 @@ func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfi
crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()),
crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnPasswordCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnPasskeyCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()),
crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()),
},
@ -78,6 +80,10 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer {
Event: session.PasswordCheckedType,
Reduce: p.reducePasswordChecked,
},
{
Event: session.PasskeyCheckedType,
Reduce: p.reducePasskeyChecked,
},
{
Event: session.TokenSetType,
Reduce: p.reduceTokenSet,
@ -165,6 +171,26 @@ func (p *sessionProjection) reducePasswordChecked(event eventstore.Event) (*hand
), nil
}
func (p *sessionProjection) reducePasskeyChecked(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.PasskeyCheckedEvent)
if !ok {
return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-WieM4", "reduce.wrong.event.type %s", session.PasskeyCheckedType)
}
return crdb.NewUpdateStatement(
e,
[]handler.Column{
handler.NewCol(SessionColumnChangeDate, e.CreationDate()),
handler.NewCol(SessionColumnSequence, e.Sequence()),
handler.NewCol(SessionColumnPasskeyCheckedAt, e.CheckedAt),
},
[]handler.Condition{
handler.NewCond(SessionColumnID, e.Aggregate().ID),
handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID),
},
), nil
}
func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.TokenSetEvent)
if !ok {

View File

@ -40,7 +40,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "INSERT INTO projections.sessions (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
expectedStmt: "INSERT INTO projections.sessions1 (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
@ -76,7 +76,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@ -109,7 +109,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@ -141,7 +141,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@ -175,7 +175,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)",
expectedArgs: []interface{}{
anyArg{},
anyArg{},
@ -207,7 +207,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.sessions WHERE (id = $1) AND (instance_id = $2)",
expectedStmt: "DELETE FROM projections.sessions1 WHERE (id = $1) AND (instance_id = $2)",
expectedArgs: []interface{}{
"agg-id",
"instance-id",
@ -234,7 +234,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{
executions: []execution{
{
expectedStmt: "DELETE FROM projections.sessions WHERE (instance_id = $1)",
expectedStmt: "DELETE FROM projections.sessions1 WHERE (instance_id = $1)",
expectedArgs: []interface{}{
"agg-id",
},

View File

@ -32,6 +32,7 @@ type Session struct {
Creator string
UserFactor SessionUserFactor
PasswordFactor SessionPasswordFactor
PasskeyFactor SessionPasskeyFactor
Metadata map[string][]byte
}
@ -46,6 +47,10 @@ type SessionPasswordFactor struct {
PasswordCheckedAt time.Time
}
type SessionPasskeyFactor struct {
PasskeyCheckedAt time.Time
}
type SessionsSearchQueries struct {
SearchRequest
Queries []SearchQuery
@ -108,6 +113,10 @@ var (
name: projection.SessionColumnPasswordCheckedAt,
table: sessionsTable,
}
SessionColumnPasskeyCheckedAt = Column{
name: projection.SessionColumnPasskeyCheckedAt,
table: sessionsTable,
}
SessionColumnMetadata = Column{
name: projection.SessionColumnMetadata,
table: sessionsTable,
@ -198,6 +207,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
LoginNameNameCol.identifier(),
HumanDisplayNameCol.identifier(),
SessionColumnPasswordCheckedAt.identifier(),
SessionColumnPasskeyCheckedAt.identifier(),
SessionColumnMetadata.identifier(),
SessionColumnToken.identifier(),
).From(sessionsTable.identifier()).
@ -212,6 +222,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
loginName sql.NullString
displayName sql.NullString
passwordCheckedAt sql.NullTime
passkeyCheckedAt sql.NullTime
metadata database.Map[[]byte]
token sql.NullString
)
@ -229,6 +240,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
&loginName,
&displayName,
&passwordCheckedAt,
&passkeyCheckedAt,
&metadata,
&token,
)
@ -245,6 +257,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil
session.UserFactor.LoginName = loginName.String
session.UserFactor.DisplayName = displayName.String
session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time
session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time
session.Metadata = metadata
return session, token.String, nil
@ -265,6 +278,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
LoginNameNameCol.identifier(),
HumanDisplayNameCol.identifier(),
SessionColumnPasswordCheckedAt.identifier(),
SessionColumnPasskeyCheckedAt.identifier(),
SessionColumnMetadata.identifier(),
countColumn.identifier(),
).From(sessionsTable.identifier()).
@ -282,6 +296,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
loginName sql.NullString
displayName sql.NullString
passwordCheckedAt sql.NullTime
passkeyCheckedAt sql.NullTime
metadata database.Map[[]byte]
)
@ -298,6 +313,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
&loginName,
&displayName,
&passwordCheckedAt,
&passkeyCheckedAt,
&metadata,
&sessions.Count,
)
@ -310,6 +326,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui
session.UserFactor.LoginName = loginName.String
session.UserFactor.DisplayName = displayName.String
session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time
session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time
session.Metadata = metadata
sessions.Sessions = append(sessions.Sessions, session)

View File

@ -17,41 +17,43 @@ import (
)
var (
expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` +
` projections.sessions.creation_date,` +
` projections.sessions.change_date,` +
` projections.sessions.sequence,` +
` projections.sessions.state,` +
` projections.sessions.resource_owner,` +
` projections.sessions.creator,` +
` projections.sessions.user_id,` +
` projections.sessions.user_checked_at,` +
expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions1.id,` +
` projections.sessions1.creation_date,` +
` projections.sessions1.change_date,` +
` projections.sessions1.sequence,` +
` projections.sessions1.state,` +
` projections.sessions1.resource_owner,` +
` projections.sessions1.creator,` +
` projections.sessions1.user_id,` +
` projections.sessions1.user_checked_at,` +
` projections.login_names2.login_name,` +
` projections.users8_humans.display_name,` +
` projections.sessions.password_checked_at,` +
` projections.sessions.metadata,` +
` projections.sessions.token_id` +
` FROM projections.sessions` +
` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` +
` projections.sessions1.password_checked_at,` +
` projections.sessions1.passkey_checked_at,` +
` projections.sessions1.metadata,` +
` projections.sessions1.token_id` +
` FROM projections.sessions1` +
` LEFT JOIN projections.login_names2 ON projections.sessions1.user_id = projections.login_names2.user_id AND projections.sessions1.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions1.user_id = projections.users8_humans.user_id AND projections.sessions1.instance_id = projections.users8_humans.instance_id` +
` AS OF SYSTEM TIME '-1 ms'`)
expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` +
` projections.sessions.creation_date,` +
` projections.sessions.change_date,` +
` projections.sessions.sequence,` +
` projections.sessions.state,` +
` projections.sessions.resource_owner,` +
` projections.sessions.creator,` +
` projections.sessions.user_id,` +
` projections.sessions.user_checked_at,` +
expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions1.id,` +
` projections.sessions1.creation_date,` +
` projections.sessions1.change_date,` +
` projections.sessions1.sequence,` +
` projections.sessions1.state,` +
` projections.sessions1.resource_owner,` +
` projections.sessions1.creator,` +
` projections.sessions1.user_id,` +
` projections.sessions1.user_checked_at,` +
` projections.login_names2.login_name,` +
` projections.users8_humans.display_name,` +
` projections.sessions.password_checked_at,` +
` projections.sessions.metadata,` +
` projections.sessions1.password_checked_at,` +
` projections.sessions1.passkey_checked_at,` +
` projections.sessions1.metadata,` +
` COUNT(*) OVER ()` +
` FROM projections.sessions` +
` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` +
` FROM projections.sessions1` +
` LEFT JOIN projections.login_names2 ON projections.sessions1.user_id = projections.login_names2.user_id AND projections.sessions1.instance_id = projections.login_names2.instance_id` +
` LEFT JOIN projections.users8_humans ON projections.sessions1.user_id = projections.users8_humans.user_id AND projections.sessions1.instance_id = projections.users8_humans.instance_id` +
` AS OF SYSTEM TIME '-1 ms'`)
sessionCols = []string{
@ -67,6 +69,7 @@ var (
"login_name",
"display_name",
"password_checked_at",
"passkey_checked_at",
"metadata",
"token",
}
@ -84,6 +87,7 @@ var (
"login_name",
"display_name",
"password_checked_at",
"passkey_checked_at",
"metadata",
"count",
}
@ -133,6 +137,7 @@ func Test_SessionsPrepare(t *testing.T) {
"login-name",
"display-name",
testNow,
testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
},
@ -160,6 +165,9 @@ func Test_SessionsPrepare(t *testing.T) {
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
PasskeyFactor: SessionPasskeyFactor{
PasskeyCheckedAt: testNow,
},
Metadata: map[string][]byte{
"key": []byte("value"),
},
@ -188,6 +196,7 @@ func Test_SessionsPrepare(t *testing.T) {
"login-name",
"display-name",
testNow,
testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
{
@ -203,6 +212,7 @@ func Test_SessionsPrepare(t *testing.T) {
"login-name2",
"display-name2",
testNow,
testNow,
[]byte(`{"key": "dmFsdWU="}`),
},
},
@ -230,6 +240,9 @@ func Test_SessionsPrepare(t *testing.T) {
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
PasskeyFactor: SessionPasskeyFactor{
PasskeyCheckedAt: testNow,
},
Metadata: map[string][]byte{
"key": []byte("value"),
},
@ -251,6 +264,9 @@ func Test_SessionsPrepare(t *testing.T) {
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
PasskeyFactor: SessionPasskeyFactor{
PasskeyCheckedAt: testNow,
},
Metadata: map[string][]byte{
"key": []byte("value"),
},
@ -332,6 +348,7 @@ func Test_SessionPrepare(t *testing.T) {
"login-name",
"display-name",
testNow,
testNow,
[]byte(`{"key": "dmFsdWU="}`),
"tokenID",
},
@ -354,6 +371,9 @@ func Test_SessionPrepare(t *testing.T) {
PasswordFactor: SessionPasswordFactor{
PasswordCheckedAt: testNow,
},
PasskeyFactor: SessionPasskeyFactor{
PasskeyCheckedAt: testNow,
},
Metadata: map[string][]byte{
"key": []byte("value"),
},

View File

@ -6,6 +6,8 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
es.RegisterFilterEventMapper(AggregateType, AddedType, AddedEventMapper).
RegisterFilterEventMapper(AggregateType, UserCheckedType, UserCheckedEventMapper).
RegisterFilterEventMapper(AggregateType, PasswordCheckedType, PasswordCheckedEventMapper).
RegisterFilterEventMapper(AggregateType, PasskeyChallengedType, eventstore.GenericEventMapper[PasskeyChallengedEvent]).
RegisterFilterEventMapper(AggregateType, PasskeyCheckedType, eventstore.GenericEventMapper[PasskeyCheckedEvent]).
RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper).
RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper).
RegisterFilterEventMapper(AggregateType, TerminateType, TerminateEventMapper)

View File

@ -5,19 +5,22 @@ import (
"encoding/json"
"time"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository"
)
const (
sessionEventPrefix = "session."
AddedType = sessionEventPrefix + "added"
UserCheckedType = sessionEventPrefix + "user.checked"
PasswordCheckedType = sessionEventPrefix + "password.checked"
TokenSetType = sessionEventPrefix + "token.set"
MetadataSetType = sessionEventPrefix + "metadata.set"
TerminateType = sessionEventPrefix + "terminated"
sessionEventPrefix = "session."
AddedType = sessionEventPrefix + "added"
UserCheckedType = sessionEventPrefix + "user.checked"
PasswordCheckedType = sessionEventPrefix + "password.checked"
PasskeyChallengedType = sessionEventPrefix + "passkey.challenged"
PasskeyCheckedType = sessionEventPrefix + "passkey.checked"
TokenSetType = sessionEventPrefix + "token.set"
MetadataSetType = sessionEventPrefix + "metadata.set"
TerminateType = sessionEventPrefix + "terminated"
)
type AddedEvent struct {
@ -141,6 +144,78 @@ func PasswordCheckedEventMapper(event *repository.Event) (eventstore.Event, erro
return added, nil
}
type PasskeyChallengedEvent struct {
eventstore.BaseEvent `json:"-"`
Challenge string `json:"challenge,omitempty"`
AllowedCrentialIDs [][]byte `json:"allowedCrentialIDs,omitempty"`
UserVerification domain.UserVerificationRequirement `json:"userVerification,omitempty"`
}
func (e *PasskeyChallengedEvent) Data() interface{} {
return e
}
func (e *PasskeyChallengedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *PasskeyChallengedEvent) SetBaseEvent(base *eventstore.BaseEvent) {
e.BaseEvent = *base
}
func NewPasskeyChallengedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
challenge string,
allowedCrentialIDs [][]byte,
userVerification domain.UserVerificationRequirement,
) *PasskeyChallengedEvent {
return &PasskeyChallengedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
PasskeyChallengedType,
),
Challenge: challenge,
AllowedCrentialIDs: allowedCrentialIDs,
UserVerification: userVerification,
}
}
type PasskeyCheckedEvent struct {
eventstore.BaseEvent `json:"-"`
CheckedAt time.Time `json:"checkedAt"`
}
func (e *PasskeyCheckedEvent) Data() interface{} {
return e
}
func (e *PasskeyCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
return nil
}
func (e *PasskeyCheckedEvent) SetBaseEvent(base *eventstore.BaseEvent) {
e.BaseEvent = *base
}
func NewPasskeyCheckedEvent(
ctx context.Context,
aggregate *eventstore.Aggregate,
checkedAt time.Time,
) *PasswordCheckedEvent {
return &PasswordCheckedEvent{
BaseEvent: *eventstore.NewBaseEventForPush(
ctx,
aggregate,
PasskeyCheckedType,
),
CheckedAt: checkedAt,
}
}
type TokenSetEvent struct {
eventstore.BaseEvent `json:"-"`

View File

@ -476,6 +476,8 @@ Errors:
Terminated: Session bereits beendet
Token:
Invalid: Session Token ist ungültig
Passkey:
NoChallenge: Sitzung ohne Passkey-Herausforderung
Intent:
IDPMissing: IDP ID fehlt im Request
SuccessURLMissing: Success URL fehlt im Request

View File

@ -476,6 +476,8 @@ Errors:
Terminated: Session already terminated
Token:
Invalid: Session Token is invalid
Passkey:
NoChallenge: Session without passkey challenge
Intent:
IDPMissing: IDP ID is missing in the request
SuccessURLMissing: Success URL is missing in the request

View File

@ -476,6 +476,8 @@ Errors:
Terminated: Sesión ya terminada
Token:
Invalid: El identificador de sesión no es válido
Passkey:
NoChallenge: Sesión sin desafío de contraseña
Intent:
IDPMissing: Falta IDP en la solicitud
SuccessURLMissing: Falta la URL de éxito en la solicitud

View File

@ -476,6 +476,8 @@ Errors:
Terminated: La session est déjà terminée
Token:
Invalid: Le jeton de session n'est pas valide
Passkey:
NoChallenge: Session sans défi de clé d'accès
Intent:
IDPMissing: IDP manquant dans la requête
SuccessURLMissing: Success URL absent de la requête

View File

@ -476,6 +476,8 @@ Errors:
Terminated: Sessione già terminata
Token:
Invalid: Il token della sessione non è valido
Passkey:
NoChallenge: Sessione senza sfida passkey
Intent:
IDPMissing: IDP mancante nella richiesta
SuccessURLMissing: URL di successo mancante nella richiesta

View File

@ -465,6 +465,8 @@ Errors:
Terminated: セッションはすでに終了しています
Token:
Invalid: セッショントークンが無効です
Passkey:
NoChallenge: パスキーチャレンジなしのセッション
Intent:
IDPMissing: リクエストにIDP IDが含まれていません
SuccessURLMissing: リクエストに成功時の URL がありません

View File

@ -476,6 +476,8 @@ Errors:
Terminated: Sesja już zakończona
Token:
Invalid: Token sesji jest nieprawidłowy
Passkey:
NoChallenge: Sesja bez wyzwania klucza
Intent:
IDPMissing: Brak identyfikatora IDP w żądaniu
SuccessURLMissing: Brak adresu URL powodzenia w żądaniu

View File

@ -476,6 +476,8 @@ Errors:
Terminated: 会话已经终止
Token:
Invalid: 会话令牌是无效的
Passkey:
NoChallenge: 没有密码挑战的会话
Intent:
IDPMissing: 请求中缺少IDP ID
SuccessURLMissing: 请求中缺少成功URL

View File

@ -4,6 +4,8 @@ import (
"fmt"
"github.com/descope/virtualwebauthn"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
)
type Client struct {
@ -25,12 +27,40 @@ func NewClient(name, domain, origin string) *Client {
}
}
func (c *Client) CreateAttestationResponse(options []byte) ([]byte, error) {
func (c *Client) CreateAttestationResponse(optionsPb *structpb.Struct) (*structpb.Struct, error) {
options, err := protojson.Marshal(optionsPb)
if err != nil {
return nil, fmt.Errorf("webauthn.Client.CreateAttestationResponse: %w", err)
}
parsedAttestationOptions, err := virtualwebauthn.ParseAttestationOptions(string(options))
if err != nil {
return nil, fmt.Errorf("webauthn.Client.CreateAttestationResponse: %w", err)
}
return []byte(virtualwebauthn.CreateAttestationResponse(
resp := new(structpb.Struct)
err = protojson.Unmarshal([]byte(virtualwebauthn.CreateAttestationResponse(
c.rp, c.auth, c.credential, *parsedAttestationOptions,
)), nil
)), resp)
if err != nil {
return nil, fmt.Errorf("webauthn.Client.CreateAttestationResponse: %w", err)
}
return resp, nil
}
func (c *Client) CreateAssertionResponse(optionsPb *structpb.Struct) (*structpb.Struct, error) {
options, err := protojson.Marshal(optionsPb)
if err != nil {
return nil, fmt.Errorf("webauthn.Client.CreateAssertionResponse: %w", err)
}
parsedAssertionOptions, err := virtualwebauthn.ParseAssertionOptions(string(options))
if err != nil {
return nil, fmt.Errorf("webauthn.Client.CreateAssertionResponse: %w", err)
}
resp := new(structpb.Struct)
err = protojson.Unmarshal([]byte(virtualwebauthn.CreateAssertionResponse(
c.rp, c.auth, c.credential, *parsedAssertionOptions,
)), resp)
if err != nil {
return nil, fmt.Errorf("webauthn.Client.CreateAssertionResponse: %w", err)
}
return resp, nil
}

View File

@ -0,0 +1,26 @@
syntax = "proto3";
package zitadel.session.v2alpha;
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
option go_package = "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha;session";
enum ChallengeKind {
CHALLENGE_KIND_UNSPECIFIED = 0;
CHALLENGE_KIND_PASSKEY = 1;
}
message Challenges {
message Passkey {
google.protobuf.Struct public_key_credential_request_options = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "Options for Assertion Generaration (dictionary PublicKeyCredentialRequestOptions). Generated helper methods transform the field to JSON, for use in a WebauthN client. See also: https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialrequestoptions"
example: "{\"publicKey\":{\"allowCredentials\":[{\"id\":\"ATmqBg-99qyOZk2zloPdJQyS2R7IkFT7v9Hoos_B_nM\",\"type\":\"public-key\"}],\"challenge\":\"GAOHYz2jE69kJMYo6Laij8yWw9-dKKgbViNhfuy0StA\",\"rpId\":\"localhost\",\"timeout\":300000,\"userVerification\":\"required\"}}"
}
];
}
optional Passkey passkey = 1;
}

View File

@ -2,7 +2,6 @@ syntax = "proto3";
package zitadel.session.v2alpha;
import "google/api/field_behavior.proto";
import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
@ -45,6 +44,7 @@ message Session {
message Factors {
UserFactor user = 1;
PasswordFactor password = 2;
PasskeyFactor passkey = 3;
}
message UserFactor {
@ -78,6 +78,14 @@ message PasswordFactor {
];
}
message PasskeyFactor {
google.protobuf.Timestamp verified_at = 1 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"time when the passkey challenge was last checked\"";
}
];
}
message SearchQuery {
oneof query {
option (validate.required) = true;

View File

@ -5,9 +5,11 @@ package zitadel.session.v2alpha;
import "zitadel/object/v2alpha/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto";
import "zitadel/session/v2alpha/challenge.proto";
import "zitadel/session/v2alpha/session.proto";
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
@ -242,6 +244,7 @@ message CreateSessionRequest{
description: "\"custom key value list to be stored on the session\"";
}
];
repeated ChallengeKind challenges = 3;
}
message CreateSessionResponse{
@ -257,6 +260,7 @@ message CreateSessionResponse{
description: "\"token of the session, which is required for further updates of the session or the request other resources\"";
}
];
Challenges challenges = 4;
}
message SetSessionRequest{
@ -287,6 +291,7 @@ message SetSessionRequest{
description: "\"custom key value list to be stored on the session\"";
}
];
repeated ChallengeKind challenges = 5;
}
message SetSessionResponse{
@ -296,6 +301,7 @@ message SetSessionResponse{
description: "\"token of the session, which is required for further updates of the session or the request other resources\"";
}
];
Challenges challenges = 3;
}
message DeleteSessionRequest{
@ -330,6 +336,11 @@ message Checks {
description: "\"Checks the password and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\"";
}
];
optional CheckPasskey passkey = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "\"Checks the public key credential issued by the passkey client. Requires that the user is already checked and a passkey challenge to be requested, in any previous request.\"";
}
];
}
message CheckUser {
@ -363,3 +374,15 @@ message CheckPassword {
}
];
}
message CheckPasskey {
google.protobuf.Struct credential_assertion_data = 1 [
(validate.rules).message.required = true,
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "JSON representation of public key credential issued by the passkey client";
min_length: 55;
max_length: 1048576; //1 MB
}
];
}

View File

@ -12,6 +12,7 @@ import "zitadel/user/v2alpha/user.proto";
import "google/api/annotations.proto";
import "google/api/field_behavior.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto";
@ -429,10 +430,10 @@ message RegisterPasskeyResponse{
example: "\"fabde5c8-c13f-481d-a90b-5e59a001a076\""
}
];
bytes public_key_credential_creation_options = 3 [
google.protobuf.Struct public_key_credential_creation_options = 3 [
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "json representation of public key credential creation options used by the passkey client"
example: "\"eyJwdWJsaWNLZXkiOnsiY2hhbGxlbmdlIoplfZm4vM21qSzBPdjltN2x6VWhnclYyejFJSlVzZnpLd0Z1TytWTWtzRW1Icz0iLCJycCI6eyJuYW1lIjoiWklUQURFTCIsImlkIjoiYWNtZS1nem9lNHgueml0YWRlbC5jbG91ZCJ9LCJ1c2VyIjp7Im5hbWUiOiJ0ZXN0dXNlcjU1QGFjbWUueml0YWRlbC5jbG91ZCIsImRpc3BsYXlOYW1lIjoiVGVzdCBUZXN0IiwiaWQiOiJNVGd5TVRVMk1qWTBNakk1TXpBMk5qSTEifSwicHViS2V5Q3JlZFBhcmFtcyI6W3sidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi03fSx7InR5cGUiOiJwdWJsaWMta2V5IiwiYWxnIjotMzV9LHsidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi0zNn0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTI1N30seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTI1OH0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTI1OX0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTM3fSx7InR5cGUiOiJwdWJsaWMta2V5IiwiYWxnIjotMzh9LHsidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi0zOX0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTh9XSwiYXV0aGVudGljYXRvclNlbGVjdGlvbiI6eyJ1c2VyVmVyaWZpY2F0aW9uIjoiZGlzY291cmFnZWQifn2ilGltZW91dCI6NjAwMDAsImF0dGVzdGF0aW9uIjoibm9uZSJ9fQ==\""
description: "Options for Credential Creation (dictionary PublicKeyCredentialCreationOptions). Generated helper methods transform the field to JSON, for use in a WebauthN client. See also: https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialcreationoptions"
example: "{\"publicKey\":{\"attestation\":\"none\",\"authenticatorSelection\":{\"userVerification\":\"required\"},\"challenge\":\"XaMYwWOZ5hj6pwtwJJlpcI-ExkO5TxevBMG4R8DoKQQ\",\"excludeCredentials\":[{\"id\":\"tVp1QfYhT8DkyEHVrv7blnpAo2YJzbZgZNBf7zPs6CI\",\"type\":\"public-key\"}],\"pubKeyCredParams\":[{\"alg\":-7,\"type\":\"public-key\"}],\"rp\":{\"id\":\"localhost\",\"name\":\"ZITADEL\"},\"timeout\":300000,\"user\":{\"displayName\":\"Tim Mohlmann\",\"id\":\"MjE1NTk4MDAwNDY0OTk4OTQw\",\"name\":\"tim\"}}}"
}
];
}
@ -456,11 +457,12 @@ message VerifyPasskeyRegistrationRequest{
example: "\"fabde5c8-c13f-481d-a90b-5e59a001a076\"";
}
];
bytes public_key_credential = 3 [
(validate.rules).bytes = {min_len: 55, max_len: 1048576},
google.protobuf.Struct public_key_credential = 3 [
(validate.rules).message.required = true,
(google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "JSON representation of public key credential issued by the passkey client";
description: "PublicKeyCredential Interface. Generated helper methods populate the field from JSON created by a WebauthN client. See also: https://www.w3.org/TR/webauthn/#publickeycredential";
example: "{\"type\":\"public-key\",\"id\":\"pawVarF4xPxLFmfCnRkwXWeTrKGzabcAi92LEI1WC00\",\"rawId\":\"pawVarF4xPxLFmfCnRkwXWeTrKGzabcAi92LEI1WC00\",\"response\":{\"attestationObject\":\"o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIgRKS3VpeE9tfExXRzkoUKnG4rQWPvtSSt4YtDGgTx32oCIQDPey-2YJ4uIg-QCM4jj6aE2U3tgMFM_RP7Efx6xRu3JGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAADju76085Yhmlt1CEOHkwLQAIKWsFWqxeMT8SxZnwp0ZMF1nk6yhs2m3AIvdixCNVgtNpQECAyYgASFYIMGUDSP2FAQn2MIfPMy7cyB_Y30VqixVgGULTBtFjfRiIlggjUGfQo3_-CrMmH3S-ZQkFKWKnNBQEAMkFtG-9A4zqW0\",\"clientDataJSON\":\"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQlhXdHh0WGxJeFZZa0pHT1dVaUVmM25zby02aXZKdWw2YmNmWHdMVlFIayIsIm9yaWdpbiI6Imh0dHBzOi8vbG9jYWxob3N0OjgwODAifQ\"}}";
min_length: 55;
max_length: 1048576; //1 MB
}