mirror of
https://github.com/zitadel/zitadel.git
synced 2025-07-14 22:38:40 +00:00
feat: session v2 passkey authentication (#5952)
This commit is contained in:
parent
f7157b65f4
commit
f456168a74
2
.github/workflows/integration.yml
vendored
2
.github/workflows/integration.yml
vendored
@ -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:
|
||||||
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
270
internal/api/grpc/session/v2/session_integration_test.go
Normal file
270
internal/api/grpc/session/v2/session_integration_test.go
Normal 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...)
|
||||||
|
})
|
||||||
|
}
|
@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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())
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
}())
|
}())
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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))
|
||||||
}
|
}
|
||||||
|
84
internal/command/session_passkey.go
Normal file
84
internal/command/session_passkey.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
130
internal/command/session_passkeys_test.go
Normal file
130
internal/command/session_passkeys_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
@ -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"),
|
||||||
},
|
},
|
||||||
|
@ -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())
|
||||||
}
|
}
|
||||||
|
77
internal/integration/client.go
Normal file
77
internal/integration/client.go
Normal 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")
|
||||||
|
}
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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",
|
||||||
},
|
},
|
||||||
|
@ -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)
|
||||||
|
@ -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"),
|
||||||
},
|
},
|
||||||
|
@ -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)
|
||||||
|
@ -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:"-"`
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 がありません
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
26
proto/zitadel/session/v2alpha/challenge.proto
Normal file
26
proto/zitadel/session/v2alpha/challenge.proto
Normal 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;
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user