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 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 run main.go setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml
- name: Run integration tests - 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 - name: Publish go coverage
uses: codecov/codecov-action@v3.1.0 uses: codecov/codecov-action@v3.1.0
with: 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} 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 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 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 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) { func TestServer_Healthz(t *testing.T) {
client := admin.NewAdminServiceClient(Tester.GRPCClientConn) _, err := Tester.Client.Admin.Healthz(context.TODO(), &admin.HealthzRequest{})
_, err := client.Healthz(context.TODO(), &admin.HealthzRequest{})
require.NoError(t, err) 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 { 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 ( import (
"context" "context"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors" caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" 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 { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@ -51,6 +55,7 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe
Details: object.DomainToDetailsPb(set.ObjectDetails), Details: object.DomainToDetailsPb(set.ObjectDetails),
SessionId: set.ID, SessionId: set.ID,
SessionToken: set.NewToken, SessionToken: set.NewToken,
Challenges: challengeResponse,
}, nil }, nil
} }
@ -59,7 +64,9 @@ func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@ -70,6 +77,7 @@ func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest)
return &session.SetSessionResponse{ return &session.SetSessionResponse{
Details: object.DomainToDetailsPb(set.ObjectDetails), Details: object.DomainToDetailsPb(set.ObjectDetails),
SessionToken: set.NewToken, SessionToken: set.NewToken,
Challenges: challengeResponse,
}, nil }, nil
} }
@ -104,13 +112,13 @@ func sessionToPb(s *query.Session) *session.Session {
func factorsToPb(s *query.Session) *session.Factors { func factorsToPb(s *query.Session) *session.Factors {
user := userFactorToPb(s.UserFactor) user := userFactorToPb(s.UserFactor)
pw := passwordFactorToPb(s.PasswordFactor) if user == nil {
if user == nil && pw == nil {
return nil return nil
} }
return &session.Factors{ return &session.Factors{
User: user, 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 { func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor {
if factor.UserID == "" || factor.UserCheckedAt.IsZero() { if factor.UserID == "" || factor.UserCheckedAt.IsZero() {
return nil return nil
@ -180,7 +197,7 @@ func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) {
return query.NewSessionIDsSearchQuery(q.Ids) 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) checks, err := s.checksToCommand(ctx, req.Checks)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@ -188,7 +205,7 @@ func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session
return checks, req.GetMetadata(), nil 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) checks, err := s.checksToCommand(ctx, req.Checks)
if err != nil { if err != nil {
return nil, err return nil, err
@ -196,12 +213,12 @@ func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.Se
return checks, nil 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()) checkUser, err := userCheck(checks.GetUser())
if err != nil { if err != nil {
return nil, err return nil, err
} }
sessionChecks := make([]command.SessionCheck, 0, 2) sessionChecks := make([]command.SessionCommand, 0, 3)
if checkUser != nil { if checkUser != nil {
user, err := checkUser.search(ctx, s.query) user, err := checkUser.search(ctx, s.query)
if err != nil { if err != nil {
@ -212,9 +229,38 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([
if password := checks.GetPassword(); password != nil { if password := checks.GetPassword(); password != nil {
sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword())) sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword()))
} }
if passkey := checks.GetPasskey(); passkey != nil {
sessionChecks = append(sessionChecks, s.command.CheckPasskey(passkey.GetCredentialAssertionData()))
}
return sessionChecks, nil 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) { func userCheck(user *session.CheckUser) (userSearch, error) {
if user == nil { if user == nil {
return nil, 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")}, Metadata: map[string][]byte{"hello": []byte("world")},
}, },
{ // no factor { // password factor
ID: "999", ID: "999",
CreationDate: now, CreationDate: now,
ChangeDate: now, ChangeDate: now,
@ -57,11 +57,36 @@ func Test_sessionsToPb(t *testing.T) {
State: domain.SessionStateActive, State: domain.SessionStateActive,
ResourceOwner: "me", ResourceOwner: "me",
Creator: "he", Creator: "he",
UserFactor: query.SessionUserFactor{
UserID: "345",
UserCheckedAt: past,
LoginName: "donald",
DisplayName: "donald duck",
},
PasswordFactor: query.SessionPasswordFactor{ PasswordFactor: query.SessionPasswordFactor{
PasswordCheckedAt: past, PasswordCheckedAt: past,
}, },
Metadata: map[string][]byte{"hello": []byte("world")}, 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{ want := []*session.Session{
@ -94,12 +119,36 @@ func Test_sessionsToPb(t *testing.T) {
ChangeDate: timestamppb.New(now), ChangeDate: timestamppb.New(now),
Sequence: 123, Sequence: 123,
Factors: &session.Factors{ Factors: &session.Factors{
User: &session.UserFactor{
VerifiedAt: timestamppb.New(past),
Id: "345",
LoginName: "donald",
DisplayName: "donald duck",
},
Password: &session.PasswordFactor{ Password: &session.PasswordFactor{
VerifiedAt: timestamppb.New(past), VerifiedAt: timestamppb.New(past),
}, },
}, },
Metadata: map[string][]byte{"hello": []byte("world")}, 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) out := sessionsToPb(sessions)
@ -107,7 +156,7 @@ func Test_sessionsToPb(t *testing.T) {
for i, got := range out { for i, got := range out {
if !proto.Equal(got, want[i]) { 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 package user_test
import ( import (
"fmt"
"testing" "testing"
"time"
"github.com/muhlemmer/gu" "github.com/muhlemmer/gu"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -16,31 +14,8 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "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) { func TestServer_SetEmail(t *testing.T) {
userID := createHumanUser(t).GetUserId() userID := Tester.CreateHumanUser(CTX).GetUserId()
tests := []struct { tests := []struct {
name string name string
@ -158,7 +133,7 @@ func TestServer_SetEmail(t *testing.T) {
} }
func TestServer_VerifyEmail(t *testing.T) { func TestServer_VerifyEmail(t *testing.T) {
userResp := createHumanUser(t) userResp := Tester.CreateHumanUser(CTX)
tests := []struct { tests := []struct {
name string name string
req *user.VerifyEmailRequest req *user.VerifyEmailRequest

View File

@ -3,6 +3,9 @@ package user
import ( import (
"context" "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/authz"
"github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/api/grpc/object/v2"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
@ -42,16 +45,24 @@ func passkeyRegistrationDetailsToPb(details *domain.PasskeyRegistrationDetails,
if err != nil { if err != nil {
return nil, err 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{ return &user.RegisterPasskeyResponse{
Details: object.DomainToDetailsPb(details.ObjectDetails), Details: object.DomainToDetailsPb(details.ObjectDetails),
PasskeyId: details.PasskeyID, PasskeyId: details.PasskeyID,
PublicKeyCredentialCreationOptions: details.PublicKeyCredentialCreationOptions, PublicKeyCredentialCreationOptions: options,
}, nil }, nil
} }
func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) { func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) {
resourceOwner := authz.GetCtxData(ctx).ResourceOwner 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 { if err != nil {
return nil, err return nil, err
} }

View File

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

View File

@ -7,10 +7,13 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
"github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/api/grpc"
"github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/domain"
caos_errs "github.com/zitadel/zitadel/internal/errors"
object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha"
user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha"
) )
@ -54,6 +57,7 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
name string name string
args args args args
want *user.RegisterPasskeyResponse want *user.RegisterPasskeyResponse
wantErr error
}{ }{
{ {
name: "an error", name: "an error",
@ -61,6 +65,23 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
details: nil, details: nil,
err: io.ErrClosedPipe, 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", name: "ok",
@ -72,7 +93,7 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
ResourceOwner: "me", ResourceOwner: "me",
}, },
PasskeyID: "123", PasskeyID: "123",
PublicKeyCredentialCreationOptions: []byte{1, 2, 3}, PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`),
}, },
err: nil, err: nil,
}, },
@ -86,15 +107,19 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) {
ResourceOwner: "me", ResourceOwner: "me",
}, },
PasskeyId: "123", PasskeyId: "123",
PublicKeyCredentialCreationOptions: []byte{1, 2, 3}, PublicKeyCredentialCreationOptions: &structpb.Struct{
Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}},
},
}, },
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err) got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err)
require.ErrorIs(t, err, tt.args.err) require.ErrorIs(t, err, tt.wantErr)
assert.Equal(t, tt.want, got) if !proto.Equal(tt.want, got) {
t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got)
}
if tt.want != nil { if tt.want != nil {
grpc.AllFieldsSet(t, got.ProtoReflect()) grpc.AllFieldsSet(t, got.ProtoReflect())
} }

View File

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

View File

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

View File

@ -6,10 +6,31 @@ import (
"time" "time"
"github.com/zitadel/zitadel/internal/domain" "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"
"github.com/zitadel/zitadel/internal/repository/session" "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 { type SessionWriteModel struct {
eventstore.WriteModel eventstore.WriteModel
@ -17,9 +38,12 @@ type SessionWriteModel struct {
UserID string UserID string
UserCheckedAt time.Time UserCheckedAt time.Time
PasswordCheckedAt time.Time PasswordCheckedAt time.Time
PasskeyCheckedAt time.Time
Metadata map[string][]byte Metadata map[string][]byte
State domain.SessionState State domain.SessionState
PasskeyChallenge *PasskeyChallengeModel
commands []eventstore.Command commands []eventstore.Command
aggregate *eventstore.Aggregate aggregate *eventstore.Aggregate
} }
@ -44,6 +68,10 @@ func (wm *SessionWriteModel) Reduce() error {
wm.reduceUserChecked(e) wm.reduceUserChecked(e)
case *session.PasswordCheckedEvent: case *session.PasswordCheckedEvent:
wm.reducePasswordChecked(e) wm.reducePasswordChecked(e)
case *session.PasskeyChallengedEvent:
wm.reducePasskeyChallenged(e)
case *session.PasskeyCheckedEvent:
wm.reducePasskeyChecked(e)
case *session.TokenSetEvent: case *session.TokenSetEvent:
wm.reduceTokenSet(e) wm.reduceTokenSet(e)
case *session.TerminateEvent: case *session.TerminateEvent:
@ -62,6 +90,8 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder {
session.AddedType, session.AddedType,
session.UserCheckedType, session.UserCheckedType,
session.PasswordCheckedType, session.PasswordCheckedType,
session.PasskeyChallengedType,
session.PasskeyCheckedType,
session.TokenSetType, session.TokenSetType,
session.MetadataSetType, session.MetadataSetType,
session.TerminateType, session.TerminateType,
@ -87,6 +117,19 @@ func (wm *SessionWriteModel) reducePasswordChecked(e *session.PasswordCheckedEve
wm.PasswordCheckedAt = e.CheckedAt 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) { func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) {
wm.TokenID = e.TokenID 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)) 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) { func (wm *SessionWriteModel) SetToken(ctx context.Context, tokenID string) {
wm.commands = append(wm.commands, session.NewTokenSetEvent(ctx, wm.aggregate, tokenID)) 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 ( import (
"context" "context"
"io"
"testing" "testing"
"time" "time"
@ -21,6 +22,121 @@ import (
"github.com/zitadel/zitadel/internal/repository/user" "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) { func TestCommands_CreateSession(t *testing.T) {
type fields struct { type fields struct {
eventstore *eventstore.Eventstore eventstore *eventstore.Eventstore
@ -29,7 +145,7 @@ func TestCommands_CreateSession(t *testing.T) {
} }
type args struct { type args struct {
ctx context.Context ctx context.Context
checks []SessionCheck checks []SessionCommand
metadata map[string][]byte metadata map[string][]byte
} }
type res struct { type res struct {
@ -126,7 +242,7 @@ func TestCommands_UpdateSession(t *testing.T) {
ctx context.Context ctx context.Context
sessionID string sessionID string
sessionToken string sessionToken string
checks []SessionCheck checks []SessionCommand
metadata map[string][]byte metadata map[string][]byte
} }
type res struct { type res struct {
@ -231,7 +347,7 @@ func TestCommands_updateSession(t *testing.T) {
} }
type args struct { type args struct {
ctx context.Context ctx context.Context
checks *SessionChecks checks *SessionCommands
metadata map[string][]byte metadata map[string][]byte
} }
type res struct { type res struct {
@ -251,7 +367,7 @@ func TestCommands_updateSession(t *testing.T) {
}, },
args{ args{
ctx: context.Background(), ctx: context.Background(),
checks: &SessionChecks{ checks: &SessionCommands{
sessionWriteModel: &SessionWriteModel{State: domain.SessionStateTerminated}, sessionWriteModel: &SessionWriteModel{State: domain.SessionStateTerminated},
}, },
}, },
@ -266,10 +382,10 @@ func TestCommands_updateSession(t *testing.T) {
}, },
args{ args{
ctx: context.Background(), ctx: context.Background(),
checks: &SessionChecks{ checks: &SessionCommands{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{ cmds: []SessionCommand{
func(ctx context.Context, cmd *SessionChecks) error { func(ctx context.Context, cmd *SessionCommands) error {
return caos_errs.ThrowInternal(nil, "id", "check failed") return caos_errs.ThrowInternal(nil, "id", "check failed")
}, },
}, },
@ -286,9 +402,9 @@ func TestCommands_updateSession(t *testing.T) {
}, },
args{ args{
ctx: context.Background(), ctx: context.Background(),
checks: &SessionChecks{ checks: &SessionCommands{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{}, cmds: []SessionCommand{},
}, },
}, },
res{ res{
@ -321,9 +437,9 @@ func TestCommands_updateSession(t *testing.T) {
}, },
args{ args{
ctx: context.Background(), ctx: context.Background(),
checks: &SessionChecks{ checks: &SessionCommands{
sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), sessionWriteModel: NewSessionWriteModel("sessionID", "org1"),
checks: []SessionCheck{ cmds: []SessionCommand{
CheckUser("userID"), CheckUser("userID"),
CheckPassword("password"), CheckPassword("password"),
}, },

View File

@ -35,7 +35,7 @@ func AssertDetails[D DetailsMsg](t testing.TB, exptected, actual D) {
gotCD := gotDetails.GetChangeDate().AsTime() gotCD := gotDetails.GetChangeDate().AsTime()
now := time.Now() 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()) 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" caos_errs "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/webauthn"
"github.com/zitadel/zitadel/pkg/grpc/admin" "github.com/zitadel/zitadel/pkg/grpc/admin"
) )
@ -68,7 +69,8 @@ type Tester struct {
Organisation *query.Org Organisation *query.Org
Users map[UserType]User Users map[UserType]User
GRPCClientConn *grpc.ClientConn Client Client
WebAuthN *webauthn.Client
wg sync.WaitGroup // used for shutdown wg sync.WaitGroup // used for shutdown
} }
@ -90,7 +92,7 @@ func (s *Tester) createClientConn(ctx context.Context) {
logging.OnError(err).Fatal("integration tester client dial") logging.OnError(err).Fatal("integration tester client dial")
logging.New().WithField("target", target).Info("finished dialing grpc client conn") logging.New().WithField("target", target).Info("finished dialing grpc client conn")
s.GRPCClientConn = cc s.Client = newClient(cc)
err = s.pollHealth(ctx) err = s.pollHealth(ctx)
logging.OnError(err).Fatal("integration tester health") 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 // TODO: remove when we make the setup blocking on all
// projections completed. // projections completed.
func (s *Tester) pollHealth(ctx context.Context) (err error) { func (s *Tester) pollHealth(ctx context.Context) (err error) {
client := admin.NewAdminServiceClient(s.GRPCClientConn)
for { for {
err = func(ctx context.Context) error { err = func(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second) ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()
_, err := client.Healthz(ctx, &admin.HealthzRequest{}) _, err := s.Client.Admin.Healthz(ctx, &admin.HealthzRequest{})
return err return err
}(ctx) }(ctx)
if err == nil { 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. // Done send an interrupt signal to cleanly shutdown the server.
func (s *Tester) Done() { func (s *Tester) Done() {
err := s.GRPCClientConn.Close() err := s.Client.CC.Close()
logging.OnError(err).Error("integration tester client close") logging.OnError(err).Error("integration tester client close")
s.Shutdown <- os.Interrupt s.Shutdown <- os.Interrupt
@ -238,6 +238,7 @@ func NewTester(ctx context.Context) *Tester {
} }
tester.createClientConn(ctx) tester.createClientConn(ctx)
tester.createSystemUser(ctx) tester.createSystemUser(ctx)
tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, "https://"+tester.Host())
return tester return tester
} }

View File

@ -13,7 +13,7 @@ import (
) )
const ( const (
SessionsProjectionTable = "projections.sessions" SessionsProjectionTable = "projections.sessions1"
SessionColumnID = "id" SessionColumnID = "id"
SessionColumnCreationDate = "creation_date" SessionColumnCreationDate = "creation_date"
@ -26,6 +26,7 @@ const (
SessionColumnUserID = "user_id" SessionColumnUserID = "user_id"
SessionColumnUserCheckedAt = "user_checked_at" SessionColumnUserCheckedAt = "user_checked_at"
SessionColumnPasswordCheckedAt = "password_checked_at" SessionColumnPasswordCheckedAt = "password_checked_at"
SessionColumnPasskeyCheckedAt = "passkey_checked_at"
SessionColumnMetadata = "metadata" SessionColumnMetadata = "metadata"
SessionColumnTokenID = "token_id" SessionColumnTokenID = "token_id"
) )
@ -51,6 +52,7 @@ func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfi
crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()), crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()),
crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()),
crdb.NewColumn(SessionColumnPasswordCheckedAt, 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(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()),
crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()), crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()),
}, },
@ -78,6 +80,10 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer {
Event: session.PasswordCheckedType, Event: session.PasswordCheckedType,
Reduce: p.reducePasswordChecked, Reduce: p.reducePasswordChecked,
}, },
{
Event: session.PasskeyCheckedType,
Reduce: p.reducePasskeyChecked,
},
{ {
Event: session.TokenSetType, Event: session.TokenSetType,
Reduce: p.reduceTokenSet, Reduce: p.reduceTokenSet,
@ -165,6 +171,26 @@ func (p *sessionProjection) reducePasswordChecked(event eventstore.Event) (*hand
), nil ), 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) { func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) {
e, ok := event.(*session.TokenSetEvent) e, ok := event.(*session.TokenSetEvent)
if !ok { if !ok {

View File

@ -40,7 +40,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ 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{}{ expectedArgs: []interface{}{
"agg-id", "agg-id",
"instance-id", "instance-id",
@ -76,7 +76,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ 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{}{ expectedArgs: []interface{}{
anyArg{}, anyArg{},
anyArg{}, anyArg{},
@ -109,7 +109,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ 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{}{ expectedArgs: []interface{}{
anyArg{}, anyArg{},
anyArg{}, anyArg{},
@ -141,7 +141,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ 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{}{ expectedArgs: []interface{}{
anyArg{}, anyArg{},
anyArg{}, anyArg{},
@ -175,7 +175,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ 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{}{ expectedArgs: []interface{}{
anyArg{}, anyArg{},
anyArg{}, anyArg{},
@ -207,7 +207,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ 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{}{ expectedArgs: []interface{}{
"agg-id", "agg-id",
"instance-id", "instance-id",
@ -234,7 +234,7 @@ func TestSessionProjection_reduces(t *testing.T) {
executer: &testExecuter{ executer: &testExecuter{
executions: []execution{ executions: []execution{
{ {
expectedStmt: "DELETE FROM projections.sessions WHERE (instance_id = $1)", expectedStmt: "DELETE FROM projections.sessions1 WHERE (instance_id = $1)",
expectedArgs: []interface{}{ expectedArgs: []interface{}{
"agg-id", "agg-id",
}, },

View File

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

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"time" "time"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/errors"
"github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/eventstore/repository" "github.com/zitadel/zitadel/internal/eventstore/repository"
@ -15,6 +16,8 @@ const (
AddedType = sessionEventPrefix + "added" AddedType = sessionEventPrefix + "added"
UserCheckedType = sessionEventPrefix + "user.checked" UserCheckedType = sessionEventPrefix + "user.checked"
PasswordCheckedType = sessionEventPrefix + "password.checked" PasswordCheckedType = sessionEventPrefix + "password.checked"
PasskeyChallengedType = sessionEventPrefix + "passkey.challenged"
PasskeyCheckedType = sessionEventPrefix + "passkey.checked"
TokenSetType = sessionEventPrefix + "token.set" TokenSetType = sessionEventPrefix + "token.set"
MetadataSetType = sessionEventPrefix + "metadata.set" MetadataSetType = sessionEventPrefix + "metadata.set"
TerminateType = sessionEventPrefix + "terminated" TerminateType = sessionEventPrefix + "terminated"
@ -141,6 +144,78 @@ func PasswordCheckedEventMapper(event *repository.Event) (eventstore.Event, erro
return added, nil 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 { type TokenSetEvent struct {
eventstore.BaseEvent `json:"-"` eventstore.BaseEvent `json:"-"`

View File

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

View File

@ -476,6 +476,8 @@ Errors:
Terminated: Session already terminated Terminated: Session already terminated
Token: Token:
Invalid: Session Token is invalid Invalid: Session Token is invalid
Passkey:
NoChallenge: Session without passkey challenge
Intent: Intent:
IDPMissing: IDP ID is missing in the request IDPMissing: IDP ID is missing in the request
SuccessURLMissing: Success URL 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 Terminated: Sesión ya terminada
Token: Token:
Invalid: El identificador de sesión no es válido Invalid: El identificador de sesión no es válido
Passkey:
NoChallenge: Sesión sin desafío de contraseña
Intent: Intent:
IDPMissing: Falta IDP en la solicitud IDPMissing: Falta IDP en la solicitud
SuccessURLMissing: Falta la URL de éxito 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 Terminated: La session est déjà terminée
Token: Token:
Invalid: Le jeton de session n'est pas valide Invalid: Le jeton de session n'est pas valide
Passkey:
NoChallenge: Session sans défi de clé d'accès
Intent: Intent:
IDPMissing: IDP manquant dans la requête IDPMissing: IDP manquant dans la requête
SuccessURLMissing: Success URL absent de la requête SuccessURLMissing: Success URL absent de la requête

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"github.com/descope/virtualwebauthn" "github.com/descope/virtualwebauthn"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
) )
type Client struct { 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)) parsedAttestationOptions, err := virtualwebauthn.ParseAttestationOptions(string(options))
if err != nil { if err != nil {
return nil, fmt.Errorf("webauthn.Client.CreateAttestationResponse: %w", err) 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, 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; package zitadel.session.v2alpha;
import "google/api/field_behavior.proto";
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "protoc-gen-openapiv2/options/annotations.proto"; import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto"; import "validate/validate.proto";
@ -45,6 +44,7 @@ message Session {
message Factors { message Factors {
UserFactor user = 1; UserFactor user = 1;
PasswordFactor password = 2; PasswordFactor password = 2;
PasskeyFactor passkey = 3;
} }
message UserFactor { 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 { message SearchQuery {
oneof query { oneof query {
option (validate.required) = true; option (validate.required) = true;

View File

@ -5,9 +5,11 @@ package zitadel.session.v2alpha;
import "zitadel/object/v2alpha/object.proto"; import "zitadel/object/v2alpha/object.proto";
import "zitadel/protoc_gen_zitadel/v2/options.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto";
import "zitadel/session/v2alpha/challenge.proto";
import "zitadel/session/v2alpha/session.proto"; import "zitadel/session/v2alpha/session.proto";
import "google/api/annotations.proto"; import "google/api/annotations.proto";
import "google/api/field_behavior.proto"; import "google/api/field_behavior.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto"; import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto"; import "validate/validate.proto";
@ -242,6 +244,7 @@ message CreateSessionRequest{
description: "\"custom key value list to be stored on the session\""; description: "\"custom key value list to be stored on the session\"";
} }
]; ];
repeated ChallengeKind challenges = 3;
} }
message CreateSessionResponse{ 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\""; description: "\"token of the session, which is required for further updates of the session or the request other resources\"";
} }
]; ];
Challenges challenges = 4;
} }
message SetSessionRequest{ message SetSessionRequest{
@ -287,6 +291,7 @@ message SetSessionRequest{
description: "\"custom key value list to be stored on the session\""; description: "\"custom key value list to be stored on the session\"";
} }
]; ];
repeated ChallengeKind challenges = 5;
} }
message SetSessionResponse{ 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\""; description: "\"token of the session, which is required for further updates of the session or the request other resources\"";
} }
]; ];
Challenges challenges = 3;
} }
message DeleteSessionRequest{ 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.\""; 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 { 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/annotations.proto";
import "google/api/field_behavior.proto"; import "google/api/field_behavior.proto";
import "google/protobuf/duration.proto"; import "google/protobuf/duration.proto";
import "google/protobuf/struct.proto";
import "protoc-gen-openapiv2/options/annotations.proto"; import "protoc-gen-openapiv2/options/annotations.proto";
import "validate/validate.proto"; import "validate/validate.proto";
@ -429,10 +430,10 @@ message RegisterPasskeyResponse{
example: "\"fabde5c8-c13f-481d-a90b-5e59a001a076\"" 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) = { (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
description: "json representation of public key credential creation options used by the passkey client" 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: "\"eyJwdWJsaWNLZXkiOnsiY2hhbGxlbmdlIoplfZm4vM21qSzBPdjltN2x6VWhnclYyejFJSlVzZnpLd0Z1TytWTWtzRW1Icz0iLCJycCI6eyJuYW1lIjoiWklUQURFTCIsImlkIjoiYWNtZS1nem9lNHgueml0YWRlbC5jbG91ZCJ9LCJ1c2VyIjp7Im5hbWUiOiJ0ZXN0dXNlcjU1QGFjbWUueml0YWRlbC5jbG91ZCIsImRpc3BsYXlOYW1lIjoiVGVzdCBUZXN0IiwiaWQiOiJNVGd5TVRVMk1qWTBNakk1TXpBMk5qSTEifSwicHViS2V5Q3JlZFBhcmFtcyI6W3sidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi03fSx7InR5cGUiOiJwdWJsaWMta2V5IiwiYWxnIjotMzV9LHsidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi0zNn0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTI1N30seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTI1OH0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTI1OX0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTM3fSx7InR5cGUiOiJwdWJsaWMta2V5IiwiYWxnIjotMzh9LHsidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi0zOX0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTh9XSwiYXV0aGVudGljYXRvclNlbGVjdGlvbiI6eyJ1c2VyVmVyaWZpY2F0aW9uIjoiZGlzY291cmFnZWQifn2ilGltZW91dCI6NjAwMDAsImF0dGVzdGF0aW9uIjoibm9uZSJ9fQ==\"" 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\""; example: "\"fabde5c8-c13f-481d-a90b-5e59a001a076\"";
} }
]; ];
bytes public_key_credential = 3 [ google.protobuf.Struct public_key_credential = 3 [
(validate.rules).bytes = {min_len: 55, max_len: 1048576}, (validate.rules).message.required = true,
(google.api.field_behavior) = REQUIRED, (google.api.field_behavior) = REQUIRED,
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { (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; min_length: 55;
max_length: 1048576; //1 MB max_length: 1048576; //1 MB
} }