diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 2347e029a..23e35ceb6 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -43,7 +43,7 @@ jobs: go run main.go init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml go run main.go setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml - name: Run integration tests - run: go test -tags=integration -race -parallel 1 -v -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./internal/integration ./internal/api/grpc/... + run: go test -tags=integration -race -p 1 -v -coverprofile=profile.cov -coverpkg=./internal/...,./cmd/... ./internal/integration ./internal/api/grpc/... - name: Publish go coverage uses: codecov/codecov-action@v3.1.0 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00238fd51..06be1797a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -208,7 +208,7 @@ export INTEGRATION_DB_FLAVOR="cockroach" ZITADEL_MASTERKEY="MasterkeyNeedsToHave docker compose -f internal/integration/config/docker-compose.yaml up --wait ${INTEGRATION_DB_FLAVOR} go run main.go init --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml go run main.go setup --masterkeyFromEnv --config internal/integration/config/zitadel.yaml --config internal/integration/config/${INTEGRATION_DB_FLAVOR}.yaml -go test -tags=integration -race -parallel 1 ./internal/integration ./internal/api/grpc/... +go test -count 1 -tags=integration -race -p 1 ./internal/integration ./internal/api/grpc/... docker compose -f internal/integration/config/docker-compose.yaml down ``` diff --git a/internal/api/grpc/admin/information_integration_test.go b/internal/api/grpc/admin/information_integration_test.go index 8eb981824..34aa52206 100644 --- a/internal/api/grpc/admin/information_integration_test.go +++ b/internal/api/grpc/admin/information_integration_test.go @@ -30,7 +30,6 @@ func TestMain(m *testing.M) { } func TestServer_Healthz(t *testing.T) { - client := admin.NewAdminServiceClient(Tester.GRPCClientConn) - _, err := client.Healthz(context.TODO(), &admin.HealthzRequest{}) + _, err := Tester.Client.Admin.Healthz(context.TODO(), &admin.HealthzRequest{}) require.NoError(t, err) } diff --git a/internal/api/grpc/fields.go b/internal/api/grpc/fields.go index f0c9d30a6..f3701b21e 100644 --- a/internal/api/grpc/fields.go +++ b/internal/api/grpc/fields.go @@ -30,7 +30,9 @@ func AllFieldsSet(t testing.TB, msg protoreflect.Message, ignoreTypes ...protore } if fd.Kind() == protoreflect.MessageKind { - AllFieldsSet(t, msg.Get(fd).Message(), ignoreTypes...) + if m, ok := msg.Get(fd).Interface().(protoreflect.Message); ok { + AllFieldsSet(t, m, ignoreTypes...) + } } } } diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index 413989ba1..f4af6d99b 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -3,11 +3,13 @@ package session import ( "context" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/domain" caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/query" session "github.com/zitadel/zitadel/pkg/grpc/session/v2alpha" @@ -43,7 +45,9 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe if err != nil { return nil, err } - set, err := s.command.CreateSession(ctx, checks, metadata) + challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks) + + set, err := s.command.CreateSession(ctx, cmds, metadata) if err != nil { return nil, err } @@ -51,6 +55,7 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe Details: object.DomainToDetailsPb(set.ObjectDetails), SessionId: set.ID, SessionToken: set.NewToken, + Challenges: challengeResponse, }, nil } @@ -59,7 +64,9 @@ func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) if err != nil { return nil, err } - set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), checks, req.GetMetadata()) + challengeResponse, cmds := s.challengesToCommand(req.GetChallenges(), checks) + + set, err := s.command.UpdateSession(ctx, req.GetSessionId(), req.GetSessionToken(), cmds, req.GetMetadata()) if err != nil { return nil, err } @@ -70,6 +77,7 @@ func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) return &session.SetSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionToken: set.NewToken, + Challenges: challengeResponse, }, nil } @@ -104,13 +112,13 @@ func sessionToPb(s *query.Session) *session.Session { func factorsToPb(s *query.Session) *session.Factors { user := userFactorToPb(s.UserFactor) - pw := passwordFactorToPb(s.PasswordFactor) - if user == nil && pw == nil { + if user == nil { return nil } return &session.Factors{ User: user, - Password: pw, + Password: passwordFactorToPb(s.PasswordFactor), + Passkey: passkeyFactorToPb(s.PasskeyFactor), } } @@ -123,6 +131,15 @@ func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFac } } +func passkeyFactorToPb(factor query.SessionPasskeyFactor) *session.PasskeyFactor { + if factor.PasskeyCheckedAt.IsZero() { + return nil + } + return &session.PasskeyFactor{ + VerifiedAt: timestamppb.New(factor.PasskeyCheckedAt), + } +} + func userFactorToPb(factor query.SessionUserFactor) *session.UserFactor { if factor.UserID == "" || factor.UserCheckedAt.IsZero() { return nil @@ -180,7 +197,7 @@ func idsQueryToQuery(q *session.IDsQuery) (query.SearchQuery, error) { return query.NewSessionIDsSearchQuery(q.Ids) } -func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCheck, map[string][]byte, error) { +func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, error) { checks, err := s.checksToCommand(ctx, req.Checks) if err != nil { return nil, nil, err @@ -188,7 +205,7 @@ func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session return checks, req.GetMetadata(), nil } -func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCheck, error) { +func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.SetSessionRequest) ([]command.SessionCommand, error) { checks, err := s.checksToCommand(ctx, req.Checks) if err != nil { return nil, err @@ -196,12 +213,12 @@ func (s *Server) setSessionRequestToCommand(ctx context.Context, req *session.Se return checks, nil } -func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([]command.SessionCheck, error) { +func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([]command.SessionCommand, error) { checkUser, err := userCheck(checks.GetUser()) if err != nil { return nil, err } - sessionChecks := make([]command.SessionCheck, 0, 2) + sessionChecks := make([]command.SessionCommand, 0, 3) if checkUser != nil { user, err := checkUser.search(ctx, s.query) if err != nil { @@ -212,9 +229,38 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([ if password := checks.GetPassword(); password != nil { sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword())) } + if passkey := checks.GetPasskey(); passkey != nil { + sessionChecks = append(sessionChecks, s.command.CheckPasskey(passkey.GetCredentialAssertionData())) + } + return sessionChecks, nil } +func (s *Server) challengesToCommand(challenges []session.ChallengeKind, cmds []command.SessionCommand) (*session.Challenges, []command.SessionCommand) { + if len(challenges) == 0 { + return nil, cmds + } + resp := new(session.Challenges) + for _, c := range challenges { + switch c { + case session.ChallengeKind_CHALLENGE_KIND_UNSPECIFIED: + continue + case session.ChallengeKind_CHALLENGE_KIND_PASSKEY: + passkeyChallenge, cmd := s.createPasskeyChallengeCommand() + resp.Passkey = passkeyChallenge + cmds = append(cmds, cmd) + } + } + return resp, cmds +} + +func (s *Server) createPasskeyChallengeCommand() (*session.Challenges_Passkey, command.SessionCommand) { + challenge := &session.Challenges_Passkey{ + PublicKeyCredentialRequestOptions: new(structpb.Struct), + } + return challenge, s.command.CreatePasskeyChallenge(domain.UserVerificationRequirementRequired, challenge.PublicKeyCredentialRequestOptions) +} + func userCheck(user *session.CheckUser) (userSearch, error) { if user == nil { return nil, nil diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go new file mode 100644 index 000000000..b4602cd8f --- /dev/null +++ b/internal/api/grpc/session/v2/session_integration_test.go @@ -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...) + }) +} diff --git a/internal/api/grpc/session/v2/session_test.go b/internal/api/grpc/session/v2/session_test.go index 6d2f38606..b8ae8df3a 100644 --- a/internal/api/grpc/session/v2/session_test.go +++ b/internal/api/grpc/session/v2/session_test.go @@ -49,7 +49,7 @@ func Test_sessionsToPb(t *testing.T) { }, Metadata: map[string][]byte{"hello": []byte("world")}, }, - { // no factor + { // password factor ID: "999", CreationDate: now, ChangeDate: now, @@ -57,11 +57,36 @@ func Test_sessionsToPb(t *testing.T) { State: domain.SessionStateActive, ResourceOwner: "me", Creator: "he", + UserFactor: query.SessionUserFactor{ + UserID: "345", + UserCheckedAt: past, + LoginName: "donald", + DisplayName: "donald duck", + }, PasswordFactor: query.SessionPasswordFactor{ PasswordCheckedAt: past, }, Metadata: map[string][]byte{"hello": []byte("world")}, }, + { // passkey factor + ID: "999", + CreationDate: now, + ChangeDate: now, + Sequence: 123, + State: domain.SessionStateActive, + ResourceOwner: "me", + Creator: "he", + UserFactor: query.SessionUserFactor{ + UserID: "345", + UserCheckedAt: past, + LoginName: "donald", + DisplayName: "donald duck", + }, + PasskeyFactor: query.SessionPasskeyFactor{ + PasskeyCheckedAt: past, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, } want := []*session.Session{ @@ -94,12 +119,36 @@ func Test_sessionsToPb(t *testing.T) { ChangeDate: timestamppb.New(now), Sequence: 123, Factors: &session.Factors{ + User: &session.UserFactor{ + VerifiedAt: timestamppb.New(past), + Id: "345", + LoginName: "donald", + DisplayName: "donald duck", + }, Password: &session.PasswordFactor{ VerifiedAt: timestamppb.New(past), }, }, Metadata: map[string][]byte{"hello": []byte("world")}, }, + { // passkey factor + Id: "999", + CreationDate: timestamppb.New(now), + ChangeDate: timestamppb.New(now), + Sequence: 123, + Factors: &session.Factors{ + User: &session.UserFactor{ + VerifiedAt: timestamppb.New(past), + Id: "345", + LoginName: "donald", + DisplayName: "donald duck", + }, + Passkey: &session.PasskeyFactor{ + VerifiedAt: timestamppb.New(past), + }, + }, + Metadata: map[string][]byte{"hello": []byte("world")}, + }, } out := sessionsToPb(sessions) @@ -107,7 +156,7 @@ func Test_sessionsToPb(t *testing.T) { for i, got := range out { if !proto.Equal(got, want[i]) { - t.Errorf("session %d got:\n%v\nwant:\n%v", i, got, want) + t.Errorf("session %d got:\n%v\nwant:\n%v", i, got, want[i]) } } } diff --git a/internal/api/grpc/user/v2/email_integration_test.go b/internal/api/grpc/user/v2/email_integration_test.go index 91c4806b2..8b3f0b013 100644 --- a/internal/api/grpc/user/v2/email_integration_test.go +++ b/internal/api/grpc/user/v2/email_integration_test.go @@ -3,9 +3,7 @@ package user_test import ( - "fmt" "testing" - "time" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" @@ -16,31 +14,8 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -func createHumanUser(t *testing.T) *user.AddHumanUserResponse { - resp, err := Client.AddHumanUser(CTX, &user.AddHumanUserRequest{ - Organisation: &object.Organisation{ - Org: &object.Organisation_OrgId{ - OrgId: Tester.Organisation.ID, - }, - }, - Profile: &user.SetHumanProfile{ - FirstName: "Mickey", - LastName: "Mouse", - }, - Email: &user.SetHumanEmail{ - Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), - Verification: &user.SetHumanEmail_ReturnCode{ - ReturnCode: &user.ReturnEmailVerificationCode{}, - }, - }, - }) - require.NoError(t, err) - require.NotEmpty(t, resp.GetUserId()) - return resp -} - func TestServer_SetEmail(t *testing.T) { - userID := createHumanUser(t).GetUserId() + userID := Tester.CreateHumanUser(CTX).GetUserId() tests := []struct { name string @@ -158,7 +133,7 @@ func TestServer_SetEmail(t *testing.T) { } func TestServer_VerifyEmail(t *testing.T) { - userResp := createHumanUser(t) + userResp := Tester.CreateHumanUser(CTX) tests := []struct { name string req *user.VerifyEmailRequest diff --git a/internal/api/grpc/user/v2/passkey.go b/internal/api/grpc/user/v2/passkey.go index eb1b276f8..e68feb681 100644 --- a/internal/api/grpc/user/v2/passkey.go +++ b/internal/api/grpc/user/v2/passkey.go @@ -3,6 +3,9 @@ package user import ( "context" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/structpb" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" @@ -42,16 +45,24 @@ func passkeyRegistrationDetailsToPb(details *domain.PasskeyRegistrationDetails, if err != nil { return nil, err } + options := new(structpb.Struct) + if err := protojson.Unmarshal(details.PublicKeyCredentialCreationOptions, options); err != nil { + return nil, caos_errs.ThrowInternal(err, "USERv2-Dohr6", "Errors.Internal") + } return &user.RegisterPasskeyResponse{ Details: object.DomainToDetailsPb(details.ObjectDetails), PasskeyId: details.PasskeyID, - PublicKeyCredentialCreationOptions: details.PublicKeyCredentialCreationOptions, + PublicKeyCredentialCreationOptions: options, }, nil } func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) { resourceOwner := authz.GetCtxData(ctx).ResourceOwner - objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), resourceOwner, req.GetPasskeyName(), "", req.GetPublicKeyCredential()) + pkc, err := protojson.Marshal(req.GetPublicKeyCredential()) + if err != nil { + return nil, caos_errs.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal") + } + objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), resourceOwner, req.GetPasskeyName(), "", pkc) if err != nil { return nil, err } diff --git a/internal/api/grpc/user/v2/passkey_integration_test.go b/internal/api/grpc/user/v2/passkey_integration_test.go index 623b2544f..5bad7ecfd 100644 --- a/internal/api/grpc/user/v2/passkey_integration_test.go +++ b/internal/api/grpc/user/v2/passkey_integration_test.go @@ -10,19 +10,18 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/internal/webauthn" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" + "google.golang.org/protobuf/types/known/structpb" ) func TestServer_RegisterPasskey(t *testing.T) { - userID := createHumanUser(t).GetUserId() + userID := Tester.CreateHumanUser(CTX).GetUserId() reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ UserId: userID, Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, }) require.NoError(t, err) - client := webauthn.NewClient(Tester.Config.WebAuthNName, Tester.Config.ExternalDomain, "https://"+Tester.Host()) type args struct { ctx context.Context @@ -125,7 +124,7 @@ func TestServer_RegisterPasskey(t *testing.T) { if tt.want != nil { assert.NotEmpty(t, got.GetPasskeyId()) assert.NotEmpty(t, got.GetPublicKeyCredentialCreationOptions()) - _, err := client.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) + _, err = Tester.WebAuthN.CreateAttestationResponse(got.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) } }) @@ -133,7 +132,7 @@ func TestServer_RegisterPasskey(t *testing.T) { } func TestServer_VerifyPasskeyRegistration(t *testing.T) { - userID := createHumanUser(t).GetUserId() + userID := Tester.CreateHumanUser(CTX).GetUserId() reg, err := Client.CreatePasskeyRegistrationLink(CTX, &user.CreatePasskeyRegistrationLinkRequest{ UserId: userID, Medium: &user.CreatePasskeyRegistrationLinkRequest_ReturnCode{}, @@ -147,8 +146,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { require.NotEmpty(t, pkr.GetPasskeyId()) require.NotEmpty(t, pkr.GetPublicKeyCredentialCreationOptions()) - client := webauthn.NewClient(Tester.Config.WebAuthNName, Tester.Config.ExternalDomain, "https://"+Tester.Host()) - attestationResponse, err := client.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) + attestationResponse, err := Tester.WebAuthN.CreateAttestationResponse(pkr.GetPublicKeyCredentialCreationOptions()) require.NoError(t, err) type args struct { @@ -167,7 +165,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { ctx: CTX, req: &user.VerifyPasskeyRegistrationRequest{ PasskeyId: pkr.GetPasskeyId(), - PublicKeyCredential: []byte(attestationResponse), + PublicKeyCredential: attestationResponse, PasskeyName: "nice name", }, }, @@ -195,10 +193,12 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { args: args{ ctx: CTX, req: &user.VerifyPasskeyRegistrationRequest{ - UserId: userID, - PasskeyId: pkr.GetPasskeyId(), - PublicKeyCredential: []byte("attestationResponseattestationResponseattestationResponse"), - PasskeyName: "nice name", + UserId: userID, + PasskeyId: pkr.GetPasskeyId(), + PublicKeyCredential: &structpb.Struct{ + Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}}, + }, + PasskeyName: "nice name", }, }, wantErr: true, @@ -219,7 +219,7 @@ func TestServer_VerifyPasskeyRegistration(t *testing.T) { } func TestServer_CreatePasskeyRegistrationLink(t *testing.T) { - userID := createHumanUser(t).GetUserId() + userID := Tester.CreateHumanUser(CTX).GetUserId() type args struct { ctx context.Context diff --git a/internal/api/grpc/user/v2/passkey_test.go b/internal/api/grpc/user/v2/passkey_test.go index d1934c3ca..b8988a73e 100644 --- a/internal/api/grpc/user/v2/passkey_test.go +++ b/internal/api/grpc/user/v2/passkey_test.go @@ -7,10 +7,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" ) @@ -51,9 +54,10 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { err error } tests := []struct { - name string - args args - want *user.RegisterPasskeyResponse + name string + args args + want *user.RegisterPasskeyResponse + wantErr error }{ { name: "an error", @@ -61,6 +65,23 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { details: nil, err: io.ErrClosedPipe, }, + wantErr: io.ErrClosedPipe, + }, + { + name: "unmarshall error", + args: args{ + details: &domain.PasskeyRegistrationDetails{ + ObjectDetails: &domain.ObjectDetails{ + Sequence: 22, + EventDate: time.Unix(3000, 22), + ResourceOwner: "me", + }, + PasskeyID: "123", + PublicKeyCredentialCreationOptions: []byte(`\\`), + }, + err: nil, + }, + wantErr: caos_errs.ThrowInternal(nil, "USERv2-Dohr6", "Errors.Internal"), }, { name: "ok", @@ -72,7 +93,7 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { ResourceOwner: "me", }, PasskeyID: "123", - PublicKeyCredentialCreationOptions: []byte{1, 2, 3}, + PublicKeyCredentialCreationOptions: []byte(`{"foo": "bar"}`), }, err: nil, }, @@ -85,16 +106,20 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { }, ResourceOwner: "me", }, - PasskeyId: "123", - PublicKeyCredentialCreationOptions: []byte{1, 2, 3}, + PasskeyId: "123", + PublicKeyCredentialCreationOptions: &structpb.Struct{ + Fields: map[string]*structpb.Value{"foo": {Kind: &structpb.Value_StringValue{StringValue: "bar"}}}, + }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err) - require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) + require.ErrorIs(t, err, tt.wantErr) + if !proto.Equal(tt.want, got) { + t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) + } if tt.want != nil { grpc.AllFieldsSet(t, got.ProtoReflect()) } diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index 601e6ee6b..137b4eabe 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -42,7 +42,7 @@ func TestMain(m *testing.M) { defer Tester.Done() CTX, ErrCTX = Tester.WithSystemAuthorization(ctx, integration.OrgOwner), errCtx - Client = user.NewUserServiceClient(Tester.GRPCClientConn) + Client = Tester.Client.UserV2 return m.Run() }()) } diff --git a/internal/command/session.go b/internal/command/session.go index cb652ea19..2feac20e0 100644 --- a/internal/command/session.go +++ b/internal/command/session.go @@ -16,10 +16,10 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/tracing" ) -type SessionCheck func(ctx context.Context, cmd *SessionChecks) error +type SessionCommand func(ctx context.Context, cmd *SessionCommands) error -type SessionChecks struct { - checks []SessionCheck +type SessionCommands struct { + cmds []SessionCommand sessionWriteModel *SessionWriteModel passwordWriteModel *HumanPasswordWriteModel @@ -29,9 +29,9 @@ type SessionChecks struct { now func() time.Time } -func (c *Commands) NewSessionChecks(checks []SessionCheck, session *SessionWriteModel) *SessionChecks { - return &SessionChecks{ - checks: checks, +func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWriteModel) *SessionCommands { + return &SessionCommands{ + cmds: cmds, sessionWriteModel: session, eventstore: c.eventstore, userPasswordAlg: c.userPasswordAlg, @@ -41,8 +41,8 @@ func (c *Commands) NewSessionChecks(checks []SessionCheck, session *SessionWrite } // CheckUser defines a user check to be executed for a session update -func CheckUser(id string) SessionCheck { - return func(ctx context.Context, cmd *SessionChecks) error { +func CheckUser(id string) SessionCommand { + return func(ctx context.Context, cmd *SessionCommands) error { if cmd.sessionWriteModel.UserID != "" && id != "" && cmd.sessionWriteModel.UserID != id { return caos_errs.ThrowInvalidArgument(nil, "", "user change not possible") } @@ -51,8 +51,8 @@ func CheckUser(id string) SessionCheck { } // CheckPassword defines a password check to be executed for a session update -func CheckPassword(password string) SessionCheck { - return func(ctx context.Context, cmd *SessionChecks) error { +func CheckPassword(password string) SessionCommand { + return func(ctx context.Context, cmd *SessionCommands) error { if cmd.sessionWriteModel.UserID == "" { return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfw3f", "Errors.User.UserIDMissing") } @@ -80,17 +80,32 @@ func CheckPassword(password string) SessionCheck { } } -// Check will execute the checks specified and return an error on the first occurrence -func (s *SessionChecks) Check(ctx context.Context) error { - for _, check := range s.checks { - if err := check(ctx, s); err != nil { +// Exec will execute the commands specified and returns an error on the first occurrence +func (s *SessionCommands) Exec(ctx context.Context) error { + for _, cmd := range s.cmds { + if err := cmd(ctx, s); err != nil { return err } } return nil } -func (s *SessionChecks) commands(ctx context.Context) (string, []eventstore.Command, error) { +func (s *SessionCommands) gethumanWriteModel(ctx context.Context) (*HumanWriteModel, error) { + if s.sessionWriteModel.UserID == "" { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eeR2e", "Errors.User.UserIDMissing") + } + humanWriteModel := NewHumanWriteModel(s.sessionWriteModel.UserID, s.sessionWriteModel.ResourceOwner) + err := s.eventstore.FilterToQueryReducer(ctx, humanWriteModel) + if err != nil { + return nil, err + } + if humanWriteModel.UserState != domain.UserStateActive { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4b3", "Errors.ie4Ai.NotFound") + } + return humanWriteModel, nil +} + +func (s *SessionCommands) commands(ctx context.Context) (string, []eventstore.Command, error) { if len(s.sessionWriteModel.commands) == 0 { return "", nil, nil } @@ -103,7 +118,7 @@ func (s *SessionChecks) commands(ctx context.Context) (string, []eventstore.Comm return token, s.sessionWriteModel.commands, nil } -func (c *Commands) CreateSession(ctx context.Context, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) { +func (c *Commands) CreateSession(ctx context.Context, cmds []SessionCommand, metadata map[string][]byte) (set *SessionChanged, err error) { sessionID, err := c.idGenerator.Next() if err != nil { return nil, err @@ -113,12 +128,12 @@ func (c *Commands) CreateSession(ctx context.Context, checks []SessionCheck, met if err != nil { return nil, err } - cmd := c.NewSessionChecks(checks, sessionWriteModel) + cmd := c.NewSessionCommands(cmds, sessionWriteModel) cmd.sessionWriteModel.Start(ctx) return c.updateSession(ctx, cmd, metadata) } -func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken string, checks []SessionCheck, metadata map[string][]byte) (set *SessionChanged, err error) { +func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken string, cmds []SessionCommand, metadata map[string][]byte) (set *SessionChanged, err error) { sessionWriteModel := NewSessionWriteModel(sessionID, authz.GetCtxData(ctx).OrgID) err = c.eventstore.FilterToQueryReducer(ctx, sessionWriteModel) if err != nil { @@ -127,7 +142,7 @@ func (c *Commands) UpdateSession(ctx context.Context, sessionID, sessionToken st if err := c.sessionPermission(ctx, sessionWriteModel, sessionToken, domain.PermissionSessionWrite); err != nil { return nil, err } - cmd := c.NewSessionChecks(checks, sessionWriteModel) + cmd := c.NewSessionCommands(cmds, sessionWriteModel) return c.updateSession(ctx, cmd, metadata) } @@ -154,12 +169,12 @@ func (c *Commands) TerminateSession(ctx context.Context, sessionID, sessionToken return writeModelToObjectDetails(&sessionWriteModel.WriteModel), nil } -// updateSession execute the [SessionChecks] where new events will be created and as well as for metadata (changes) -func (c *Commands) updateSession(ctx context.Context, checks *SessionChecks, metadata map[string][]byte) (set *SessionChanged, err error) { +// updateSession execute the [SessionCommands] where new events will be created and as well as for metadata (changes) +func (c *Commands) updateSession(ctx context.Context, checks *SessionCommands, metadata map[string][]byte) (set *SessionChanged, err error) { if checks.sessionWriteModel.State == domain.SessionStateTerminated { return nil, caos_errs.ThrowPreconditionFailed(nil, "COMAND-SAjeh", "Errors.Session.Terminated") } - if err := checks.Check(ctx); err != nil { + if err := checks.Exec(ctx); err != nil { // TODO: how to handle failed checks (e.g. pw wrong) https://github.com/zitadel/zitadel/issues/5807 return nil, err } diff --git a/internal/command/session_model.go b/internal/command/session_model.go index e33169381..24edbfd2e 100644 --- a/internal/command/session_model.go +++ b/internal/command/session_model.go @@ -6,10 +6,31 @@ import ( "time" "github.com/zitadel/zitadel/internal/domain" + caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/session" + usr_repo "github.com/zitadel/zitadel/internal/repository/user" ) +type PasskeyChallengeModel struct { + Challenge string + AllowedCrentialIDs [][]byte + UserVerification domain.UserVerificationRequirement +} + +func (p *PasskeyChallengeModel) WebAuthNLogin(human *domain.Human, credentialAssertionData []byte) (*domain.WebAuthNLogin, error) { + if p == nil { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Ioqu5", "Errors.Session.Passkey.NoChallenge") + } + return &domain.WebAuthNLogin{ + ObjectRoot: human.ObjectRoot, + CredentialAssertionData: credentialAssertionData, + Challenge: p.Challenge, + AllowedCredentialIDs: p.AllowedCrentialIDs, + UserVerification: p.UserVerification, + }, nil +} + type SessionWriteModel struct { eventstore.WriteModel @@ -17,9 +38,12 @@ type SessionWriteModel struct { UserID string UserCheckedAt time.Time PasswordCheckedAt time.Time + PasskeyCheckedAt time.Time Metadata map[string][]byte State domain.SessionState + PasskeyChallenge *PasskeyChallengeModel + commands []eventstore.Command aggregate *eventstore.Aggregate } @@ -44,6 +68,10 @@ func (wm *SessionWriteModel) Reduce() error { wm.reduceUserChecked(e) case *session.PasswordCheckedEvent: wm.reducePasswordChecked(e) + case *session.PasskeyChallengedEvent: + wm.reducePasskeyChallenged(e) + case *session.PasskeyCheckedEvent: + wm.reducePasskeyChecked(e) case *session.TokenSetEvent: wm.reduceTokenSet(e) case *session.TerminateEvent: @@ -62,6 +90,8 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder { session.AddedType, session.UserCheckedType, session.PasswordCheckedType, + session.PasskeyChallengedType, + session.PasskeyCheckedType, session.TokenSetType, session.MetadataSetType, session.TerminateType, @@ -87,6 +117,19 @@ func (wm *SessionWriteModel) reducePasswordChecked(e *session.PasswordCheckedEve wm.PasswordCheckedAt = e.CheckedAt } +func (wm *SessionWriteModel) reducePasskeyChallenged(e *session.PasskeyChallengedEvent) { + wm.PasskeyChallenge = &PasskeyChallengeModel{ + Challenge: e.Challenge, + AllowedCrentialIDs: e.AllowedCrentialIDs, + UserVerification: e.UserVerification, + } +} + +func (wm *SessionWriteModel) reducePasskeyChecked(e *session.PasskeyCheckedEvent) { + wm.PasskeyChallenge = nil + wm.PasskeyCheckedAt = e.CheckedAt +} + func (wm *SessionWriteModel) reduceTokenSet(e *session.TokenSetEvent) { wm.TokenID = e.TokenID } @@ -110,6 +153,17 @@ func (wm *SessionWriteModel) PasswordChecked(ctx context.Context, checkedAt time wm.commands = append(wm.commands, session.NewPasswordCheckedEvent(ctx, wm.aggregate, checkedAt)) } +func (wm *SessionWriteModel) PasskeyChallenged(ctx context.Context, challenge string, allowedCrentialIDs [][]byte, userVerification domain.UserVerificationRequirement) { + wm.commands = append(wm.commands, session.NewPasskeyChallengedEvent(ctx, wm.aggregate, challenge, allowedCrentialIDs, userVerification)) +} + +func (wm *SessionWriteModel) PasskeyChecked(ctx context.Context, checkedAt time.Time, tokenID string, signCount uint32) { + wm.commands = append(wm.commands, + session.NewPasskeyCheckedEvent(ctx, wm.aggregate, checkedAt), + usr_repo.NewHumanPasswordlessSignCountChangedEvent(ctx, wm.aggregate, tokenID, signCount), + ) +} + func (wm *SessionWriteModel) SetToken(ctx context.Context, tokenID string) { wm.commands = append(wm.commands, session.NewTokenSetEvent(ctx, wm.aggregate, tokenID)) } diff --git a/internal/command/session_passkey.go b/internal/command/session_passkey.go new file mode 100644 index 000000000..d4664ca2f --- /dev/null +++ b/internal/command/session_passkey.go @@ -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 + } +} diff --git a/internal/command/session_passkeys_test.go b/internal/command/session_passkeys_test.go new file mode 100644 index 000000000..fdc324537 --- /dev/null +++ b/internal/command/session_passkeys_test.go @@ -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) + } +} diff --git a/internal/command/session_test.go b/internal/command/session_test.go index eb080480f..b9e24b03e 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -2,6 +2,7 @@ package command import ( "context" + "io" "testing" "time" @@ -21,6 +22,121 @@ import ( "github.com/zitadel/zitadel/internal/repository/user" ) +func TestSessionCommands_getHumanWriteModel(t *testing.T) { + userAggr := &user.NewAggregate("user1", "org1").Aggregate + + type fields struct { + eventstore *eventstore.Eventstore + sessionWriteModel *SessionWriteModel + } + type res struct { + want *HumanWriteModel + err error + } + tests := []struct { + name string + fields fields + res res + }{ + { + name: "missing UID", + fields: fields{ + eventstore: &eventstore.Eventstore{}, + sessionWriteModel: &SessionWriteModel{}, + }, + res: res{ + want: nil, + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-eeR2e", "Errors.User.UserIDMissing"), + }, + }, + { + name: "filter error", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilterError(io.ErrClosedPipe), + ), + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + }, + }, + res: res{ + want: nil, + err: io.ErrClosedPipe, + }, + }, + { + name: "removed user", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + userAggr, + "", "", "", "", "", language.Georgian, + domain.GenderDiverse, "", true, + ), + ), + eventFromEventPusher( + user.NewUserRemovedEvent(context.Background(), + userAggr, + "", nil, true, + ), + ), + ), + ), + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + }, + }, + res: res{ + want: nil, + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4b3", "Errors.ie4Ai.NotFound"), + }, + }, + { + name: "ok", + fields: fields{ + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + userAggr, + "", "", "", "", "", language.Georgian, + domain.GenderDiverse, "", true, + ), + ), + ), + ), + sessionWriteModel: &SessionWriteModel{ + UserID: "user1", + }, + }, + res: res{ + want: &HumanWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: "user1", + ResourceOwner: "org1", + Events: []eventstore.Event{}, + }, + PreferredLanguage: language.Georgian, + Gender: domain.GenderDiverse, + UserState: domain.UserStateActive, + }, + err: nil, + }, + }, + } + for _, tt := range tests { + s := &SessionCommands{ + eventstore: tt.fields.eventstore, + sessionWriteModel: tt.fields.sessionWriteModel, + } + got, err := s.gethumanWriteModel(context.Background()) + require.ErrorIs(t, err, tt.res.err) + assert.Equal(t, tt.res.want, got) + } +} + func TestCommands_CreateSession(t *testing.T) { type fields struct { eventstore *eventstore.Eventstore @@ -29,7 +145,7 @@ func TestCommands_CreateSession(t *testing.T) { } type args struct { ctx context.Context - checks []SessionCheck + checks []SessionCommand metadata map[string][]byte } type res struct { @@ -126,7 +242,7 @@ func TestCommands_UpdateSession(t *testing.T) { ctx context.Context sessionID string sessionToken string - checks []SessionCheck + checks []SessionCommand metadata map[string][]byte } type res struct { @@ -231,7 +347,7 @@ func TestCommands_updateSession(t *testing.T) { } type args struct { ctx context.Context - checks *SessionChecks + checks *SessionCommands metadata map[string][]byte } type res struct { @@ -251,7 +367,7 @@ func TestCommands_updateSession(t *testing.T) { }, args{ ctx: context.Background(), - checks: &SessionChecks{ + checks: &SessionCommands{ sessionWriteModel: &SessionWriteModel{State: domain.SessionStateTerminated}, }, }, @@ -266,10 +382,10 @@ func TestCommands_updateSession(t *testing.T) { }, args{ ctx: context.Background(), - checks: &SessionChecks{ + checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - checks: []SessionCheck{ - func(ctx context.Context, cmd *SessionChecks) error { + cmds: []SessionCommand{ + func(ctx context.Context, cmd *SessionCommands) error { return caos_errs.ThrowInternal(nil, "id", "check failed") }, }, @@ -286,9 +402,9 @@ func TestCommands_updateSession(t *testing.T) { }, args{ ctx: context.Background(), - checks: &SessionChecks{ + checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - checks: []SessionCheck{}, + cmds: []SessionCommand{}, }, }, res{ @@ -321,9 +437,9 @@ func TestCommands_updateSession(t *testing.T) { }, args{ ctx: context.Background(), - checks: &SessionChecks{ + checks: &SessionCommands{ sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), - checks: []SessionCheck{ + cmds: []SessionCommand{ CheckUser("userID"), CheckPassword("password"), }, diff --git a/internal/integration/assert.go b/internal/integration/assert.go index 336139298..e5105d8d2 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -35,7 +35,7 @@ func AssertDetails[D DetailsMsg](t testing.TB, exptected, actual D) { gotCD := gotDetails.GetChangeDate().AsTime() now := time.Now() - assert.WithinRange(t, gotCD, now.Add(-time.Second), now.Add(time.Second)) + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) assert.Equal(t, wantDetails.GetResourceOwner(), gotDetails.GetResourceOwner()) } diff --git a/internal/integration/client.go b/internal/integration/client.go new file mode 100644 index 000000000..c21b42f67 --- /dev/null +++ b/internal/integration/client.go @@ -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") +} diff --git a/internal/integration/integration.go b/internal/integration/integration.go index 67ef9d60b..6ffec11e1 100644 --- a/internal/integration/integration.go +++ b/internal/integration/integration.go @@ -29,6 +29,7 @@ import ( caos_errs "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/webauthn" "github.com/zitadel/zitadel/pkg/grpc/admin" ) @@ -68,8 +69,9 @@ type Tester struct { Organisation *query.Org Users map[UserType]User - GRPCClientConn *grpc.ClientConn - wg sync.WaitGroup // used for shutdown + Client Client + WebAuthN *webauthn.Client + wg sync.WaitGroup // used for shutdown } const commandLine = `start --masterkeyFromEnv` @@ -90,7 +92,7 @@ func (s *Tester) createClientConn(ctx context.Context) { logging.OnError(err).Fatal("integration tester client dial") logging.New().WithField("target", target).Info("finished dialing grpc client conn") - s.GRPCClientConn = cc + s.Client = newClient(cc) err = s.pollHealth(ctx) logging.OnError(err).Fatal("integration tester health") } @@ -99,14 +101,12 @@ func (s *Tester) createClientConn(ctx context.Context) { // TODO: remove when we make the setup blocking on all // projections completed. func (s *Tester) pollHealth(ctx context.Context) (err error) { - client := admin.NewAdminServiceClient(s.GRPCClientConn) - for { err = func(ctx context.Context) error { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() - _, err := client.Healthz(ctx, &admin.HealthzRequest{}) + _, err := s.Client.Admin.Healthz(ctx, &admin.HealthzRequest{}) return err }(ctx) if err == nil { @@ -182,7 +182,7 @@ func (s *Tester) WithSystemAuthorization(ctx context.Context, u UserType) contex // Done send an interrupt signal to cleanly shutdown the server. func (s *Tester) Done() { - err := s.GRPCClientConn.Close() + err := s.Client.CC.Close() logging.OnError(err).Error("integration tester client close") s.Shutdown <- os.Interrupt @@ -238,6 +238,7 @@ func NewTester(ctx context.Context) *Tester { } tester.createClientConn(ctx) tester.createSystemUser(ctx) + tester.WebAuthN = webauthn.NewClient(tester.Config.WebAuthNName, tester.Config.ExternalDomain, "https://"+tester.Host()) return tester } diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go index 9858de473..d40302a84 100644 --- a/internal/query/projection/session.go +++ b/internal/query/projection/session.go @@ -13,7 +13,7 @@ import ( ) const ( - SessionsProjectionTable = "projections.sessions" + SessionsProjectionTable = "projections.sessions1" SessionColumnID = "id" SessionColumnCreationDate = "creation_date" @@ -26,6 +26,7 @@ const ( SessionColumnUserID = "user_id" SessionColumnUserCheckedAt = "user_checked_at" SessionColumnPasswordCheckedAt = "password_checked_at" + SessionColumnPasskeyCheckedAt = "passkey_checked_at" SessionColumnMetadata = "metadata" SessionColumnTokenID = "token_id" ) @@ -51,6 +52,7 @@ func newSessionProjection(ctx context.Context, config crdb.StatementHandlerConfi crdb.NewColumn(SessionColumnUserID, crdb.ColumnTypeText, crdb.Nullable()), crdb.NewColumn(SessionColumnUserCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnPasswordCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), + crdb.NewColumn(SessionColumnPasskeyCheckedAt, crdb.ColumnTypeTimestamp, crdb.Nullable()), crdb.NewColumn(SessionColumnMetadata, crdb.ColumnTypeJSONB, crdb.Nullable()), crdb.NewColumn(SessionColumnTokenID, crdb.ColumnTypeText, crdb.Nullable()), }, @@ -78,6 +80,10 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer { Event: session.PasswordCheckedType, Reduce: p.reducePasswordChecked, }, + { + Event: session.PasskeyCheckedType, + Reduce: p.reducePasskeyChecked, + }, { Event: session.TokenSetType, Reduce: p.reduceTokenSet, @@ -165,6 +171,26 @@ func (p *sessionProjection) reducePasswordChecked(event eventstore.Event) (*hand ), nil } +func (p *sessionProjection) reducePasskeyChecked(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.PasskeyCheckedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-WieM4", "reduce.wrong.event.type %s", session.PasskeyCheckedType) + } + + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnPasskeyCheckedAt, e.CheckedAt), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + func (p *sessionProjection) reduceTokenSet(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*session.TokenSetEvent) if !ok { diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go index 6cd3ae2e5..ecb96368c 100644 --- a/internal/query/projection/session_test.go +++ b/internal/query/projection/session_test.go @@ -40,7 +40,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "INSERT INTO projections.sessions (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + expectedStmt: "INSERT INTO projections.sessions1 (id, instance_id, creation_date, change_date, resource_owner, state, sequence, creator) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", expectedArgs: []interface{}{ "agg-id", "instance-id", @@ -76,7 +76,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)", + expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -109,7 +109,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -141,7 +141,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -175,7 +175,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -207,7 +207,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.sessions WHERE (id = $1) AND (instance_id = $2)", + expectedStmt: "DELETE FROM projections.sessions1 WHERE (id = $1) AND (instance_id = $2)", expectedArgs: []interface{}{ "agg-id", "instance-id", @@ -234,7 +234,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.sessions WHERE (instance_id = $1)", + expectedStmt: "DELETE FROM projections.sessions1 WHERE (instance_id = $1)", expectedArgs: []interface{}{ "agg-id", }, diff --git a/internal/query/session.go b/internal/query/session.go index ba30c64da..2dfc622a7 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -32,6 +32,7 @@ type Session struct { Creator string UserFactor SessionUserFactor PasswordFactor SessionPasswordFactor + PasskeyFactor SessionPasskeyFactor Metadata map[string][]byte } @@ -46,6 +47,10 @@ type SessionPasswordFactor struct { PasswordCheckedAt time.Time } +type SessionPasskeyFactor struct { + PasskeyCheckedAt time.Time +} + type SessionsSearchQueries struct { SearchRequest Queries []SearchQuery @@ -108,6 +113,10 @@ var ( name: projection.SessionColumnPasswordCheckedAt, table: sessionsTable, } + SessionColumnPasskeyCheckedAt = Column{ + name: projection.SessionColumnPasskeyCheckedAt, + table: sessionsTable, + } SessionColumnMetadata = Column{ name: projection.SessionColumnMetadata, table: sessionsTable, @@ -198,6 +207,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil LoginNameNameCol.identifier(), HumanDisplayNameCol.identifier(), SessionColumnPasswordCheckedAt.identifier(), + SessionColumnPasskeyCheckedAt.identifier(), SessionColumnMetadata.identifier(), SessionColumnToken.identifier(), ).From(sessionsTable.identifier()). @@ -212,6 +222,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil loginName sql.NullString displayName sql.NullString passwordCheckedAt sql.NullTime + passkeyCheckedAt sql.NullTime metadata database.Map[[]byte] token sql.NullString ) @@ -229,6 +240,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil &loginName, &displayName, &passwordCheckedAt, + &passkeyCheckedAt, &metadata, &token, ) @@ -245,6 +257,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil session.UserFactor.LoginName = loginName.String session.UserFactor.DisplayName = displayName.String session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time + session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time session.Metadata = metadata return session, token.String, nil @@ -265,6 +278,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui LoginNameNameCol.identifier(), HumanDisplayNameCol.identifier(), SessionColumnPasswordCheckedAt.identifier(), + SessionColumnPasskeyCheckedAt.identifier(), SessionColumnMetadata.identifier(), countColumn.identifier(), ).From(sessionsTable.identifier()). @@ -282,6 +296,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui loginName sql.NullString displayName sql.NullString passwordCheckedAt sql.NullTime + passkeyCheckedAt sql.NullTime metadata database.Map[[]byte] ) @@ -298,6 +313,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui &loginName, &displayName, &passwordCheckedAt, + &passkeyCheckedAt, &metadata, &sessions.Count, ) @@ -310,6 +326,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui session.UserFactor.LoginName = loginName.String session.UserFactor.DisplayName = displayName.String session.PasswordFactor.PasswordCheckedAt = passwordCheckedAt.Time + session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time session.Metadata = metadata sessions.Sessions = append(sessions.Sessions, session) diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go index eeff881d8..03a5b2197 100644 --- a/internal/query/sessions_test.go +++ b/internal/query/sessions_test.go @@ -17,41 +17,43 @@ import ( ) var ( - expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` + - ` projections.sessions.creation_date,` + - ` projections.sessions.change_date,` + - ` projections.sessions.sequence,` + - ` projections.sessions.state,` + - ` projections.sessions.resource_owner,` + - ` projections.sessions.creator,` + - ` projections.sessions.user_id,` + - ` projections.sessions.user_checked_at,` + + expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions1.id,` + + ` projections.sessions1.creation_date,` + + ` projections.sessions1.change_date,` + + ` projections.sessions1.sequence,` + + ` projections.sessions1.state,` + + ` projections.sessions1.resource_owner,` + + ` projections.sessions1.creator,` + + ` projections.sessions1.user_id,` + + ` projections.sessions1.user_checked_at,` + ` projections.login_names2.login_name,` + ` projections.users8_humans.display_name,` + - ` projections.sessions.password_checked_at,` + - ` projections.sessions.metadata,` + - ` projections.sessions.token_id` + - ` FROM projections.sessions` + - ` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` + - ` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` + + ` projections.sessions1.password_checked_at,` + + ` projections.sessions1.passkey_checked_at,` + + ` projections.sessions1.metadata,` + + ` projections.sessions1.token_id` + + ` FROM projections.sessions1` + + ` LEFT JOIN projections.login_names2 ON projections.sessions1.user_id = projections.login_names2.user_id AND projections.sessions1.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions1.user_id = projections.users8_humans.user_id AND projections.sessions1.instance_id = projections.users8_humans.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) - expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions.id,` + - ` projections.sessions.creation_date,` + - ` projections.sessions.change_date,` + - ` projections.sessions.sequence,` + - ` projections.sessions.state,` + - ` projections.sessions.resource_owner,` + - ` projections.sessions.creator,` + - ` projections.sessions.user_id,` + - ` projections.sessions.user_checked_at,` + + expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions1.id,` + + ` projections.sessions1.creation_date,` + + ` projections.sessions1.change_date,` + + ` projections.sessions1.sequence,` + + ` projections.sessions1.state,` + + ` projections.sessions1.resource_owner,` + + ` projections.sessions1.creator,` + + ` projections.sessions1.user_id,` + + ` projections.sessions1.user_checked_at,` + ` projections.login_names2.login_name,` + ` projections.users8_humans.display_name,` + - ` projections.sessions.password_checked_at,` + - ` projections.sessions.metadata,` + + ` projections.sessions1.password_checked_at,` + + ` projections.sessions1.passkey_checked_at,` + + ` projections.sessions1.metadata,` + ` COUNT(*) OVER ()` + - ` FROM projections.sessions` + - ` LEFT JOIN projections.login_names2 ON projections.sessions.user_id = projections.login_names2.user_id AND projections.sessions.instance_id = projections.login_names2.instance_id` + - ` LEFT JOIN projections.users8_humans ON projections.sessions.user_id = projections.users8_humans.user_id AND projections.sessions.instance_id = projections.users8_humans.instance_id` + + ` FROM projections.sessions1` + + ` LEFT JOIN projections.login_names2 ON projections.sessions1.user_id = projections.login_names2.user_id AND projections.sessions1.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions1.user_id = projections.users8_humans.user_id AND projections.sessions1.instance_id = projections.users8_humans.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) sessionCols = []string{ @@ -67,6 +69,7 @@ var ( "login_name", "display_name", "password_checked_at", + "passkey_checked_at", "metadata", "token", } @@ -84,6 +87,7 @@ var ( "login_name", "display_name", "password_checked_at", + "passkey_checked_at", "metadata", "count", } @@ -133,6 +137,7 @@ func Test_SessionsPrepare(t *testing.T) { "login-name", "display-name", testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), }, }, @@ -160,6 +165,9 @@ func Test_SessionsPrepare(t *testing.T) { PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, }, + PasskeyFactor: SessionPasskeyFactor{ + PasskeyCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, @@ -188,6 +196,7 @@ func Test_SessionsPrepare(t *testing.T) { "login-name", "display-name", testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), }, { @@ -203,6 +212,7 @@ func Test_SessionsPrepare(t *testing.T) { "login-name2", "display-name2", testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), }, }, @@ -230,6 +240,9 @@ func Test_SessionsPrepare(t *testing.T) { PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, }, + PasskeyFactor: SessionPasskeyFactor{ + PasskeyCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, @@ -251,6 +264,9 @@ func Test_SessionsPrepare(t *testing.T) { PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, }, + PasskeyFactor: SessionPasskeyFactor{ + PasskeyCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, @@ -332,6 +348,7 @@ func Test_SessionPrepare(t *testing.T) { "login-name", "display-name", testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), "tokenID", }, @@ -354,6 +371,9 @@ func Test_SessionPrepare(t *testing.T) { PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, }, + PasskeyFactor: SessionPasskeyFactor{ + PasskeyCheckedAt: testNow, + }, Metadata: map[string][]byte{ "key": []byte("value"), }, diff --git a/internal/repository/session/eventstore.go b/internal/repository/session/eventstore.go index 3dd1875da..3253cc856 100644 --- a/internal/repository/session/eventstore.go +++ b/internal/repository/session/eventstore.go @@ -6,6 +6,8 @@ func RegisterEventMappers(es *eventstore.Eventstore) { es.RegisterFilterEventMapper(AggregateType, AddedType, AddedEventMapper). RegisterFilterEventMapper(AggregateType, UserCheckedType, UserCheckedEventMapper). RegisterFilterEventMapper(AggregateType, PasswordCheckedType, PasswordCheckedEventMapper). + RegisterFilterEventMapper(AggregateType, PasskeyChallengedType, eventstore.GenericEventMapper[PasskeyChallengedEvent]). + RegisterFilterEventMapper(AggregateType, PasskeyCheckedType, eventstore.GenericEventMapper[PasskeyCheckedEvent]). RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper). RegisterFilterEventMapper(AggregateType, MetadataSetType, MetadataSetEventMapper). RegisterFilterEventMapper(AggregateType, TerminateType, TerminateEventMapper) diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go index 688383442..887f056e8 100644 --- a/internal/repository/session/session.go +++ b/internal/repository/session/session.go @@ -5,19 +5,22 @@ import ( "encoding/json" "time" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/errors" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/repository" ) const ( - sessionEventPrefix = "session." - AddedType = sessionEventPrefix + "added" - UserCheckedType = sessionEventPrefix + "user.checked" - PasswordCheckedType = sessionEventPrefix + "password.checked" - TokenSetType = sessionEventPrefix + "token.set" - MetadataSetType = sessionEventPrefix + "metadata.set" - TerminateType = sessionEventPrefix + "terminated" + sessionEventPrefix = "session." + AddedType = sessionEventPrefix + "added" + UserCheckedType = sessionEventPrefix + "user.checked" + PasswordCheckedType = sessionEventPrefix + "password.checked" + PasskeyChallengedType = sessionEventPrefix + "passkey.challenged" + PasskeyCheckedType = sessionEventPrefix + "passkey.checked" + TokenSetType = sessionEventPrefix + "token.set" + MetadataSetType = sessionEventPrefix + "metadata.set" + TerminateType = sessionEventPrefix + "terminated" ) type AddedEvent struct { @@ -141,6 +144,78 @@ func PasswordCheckedEventMapper(event *repository.Event) (eventstore.Event, erro return added, nil } +type PasskeyChallengedEvent struct { + eventstore.BaseEvent `json:"-"` + + Challenge string `json:"challenge,omitempty"` + AllowedCrentialIDs [][]byte `json:"allowedCrentialIDs,omitempty"` + UserVerification domain.UserVerificationRequirement `json:"userVerification,omitempty"` +} + +func (e *PasskeyChallengedEvent) Data() interface{} { + return e +} + +func (e *PasskeyChallengedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *PasskeyChallengedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = *base +} + +func NewPasskeyChallengedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + challenge string, + allowedCrentialIDs [][]byte, + userVerification domain.UserVerificationRequirement, +) *PasskeyChallengedEvent { + return &PasskeyChallengedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + PasskeyChallengedType, + ), + Challenge: challenge, + AllowedCrentialIDs: allowedCrentialIDs, + UserVerification: userVerification, + } +} + +type PasskeyCheckedEvent struct { + eventstore.BaseEvent `json:"-"` + + CheckedAt time.Time `json:"checkedAt"` +} + +func (e *PasskeyCheckedEvent) Data() interface{} { + return e +} + +func (e *PasskeyCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func (e *PasskeyCheckedEvent) SetBaseEvent(base *eventstore.BaseEvent) { + e.BaseEvent = *base +} + +func NewPasskeyCheckedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + checkedAt time.Time, +) *PasswordCheckedEvent { + return &PasswordCheckedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + PasskeyCheckedType, + ), + CheckedAt: checkedAt, + } +} + type TokenSetEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 441b6be36..6d830b2de 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -476,6 +476,8 @@ Errors: Terminated: Session bereits beendet Token: Invalid: Session Token ist ungültig + Passkey: + NoChallenge: Sitzung ohne Passkey-Herausforderung Intent: IDPMissing: IDP ID fehlt im Request SuccessURLMissing: Success URL fehlt im Request diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 276ad07da..88763384e 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -476,6 +476,8 @@ Errors: Terminated: Session already terminated Token: Invalid: Session Token is invalid + Passkey: + NoChallenge: Session without passkey challenge Intent: IDPMissing: IDP ID is missing in the request SuccessURLMissing: Success URL is missing in the request diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 757a73054..dbfa308c8 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -476,6 +476,8 @@ Errors: Terminated: Sesión ya terminada Token: Invalid: El identificador de sesión no es válido + Passkey: + NoChallenge: Sesión sin desafío de contraseña Intent: IDPMissing: Falta IDP en la solicitud SuccessURLMissing: Falta la URL de éxito en la solicitud diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index eb5b5471e..9d4c03ed6 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -476,6 +476,8 @@ Errors: Terminated: La session est déjà terminée Token: Invalid: Le jeton de session n'est pas valide + Passkey: + NoChallenge: Session sans défi de clé d'accès Intent: IDPMissing: IDP manquant dans la requête SuccessURLMissing: Success URL absent de la requête diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 7ce4200b7..113e99c30 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -476,6 +476,8 @@ Errors: Terminated: Sessione già terminata Token: Invalid: Il token della sessione non è valido + Passkey: + NoChallenge: Sessione senza sfida passkey Intent: IDPMissing: IDP mancante nella richiesta SuccessURLMissing: URL di successo mancante nella richiesta diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 394880e11..fd2fd5abe 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -465,6 +465,8 @@ Errors: Terminated: セッションはすでに終了しています Token: Invalid: セッショントークンが無効です + Passkey: + NoChallenge: パスキーチャレンジなしのセッション Intent: IDPMissing: リクエストにIDP IDが含まれていません SuccessURLMissing: リクエストに成功時の URL がありません diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 3be75368b..0c7e56ccd 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -476,6 +476,8 @@ Errors: Terminated: Sesja już zakończona Token: Invalid: Token sesji jest nieprawidłowy + Passkey: + NoChallenge: Sesja bez wyzwania klucza Intent: IDPMissing: Brak identyfikatora IDP w żądaniu SuccessURLMissing: Brak adresu URL powodzenia w żądaniu diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index eb3a76c77..3a1508e05 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -476,6 +476,8 @@ Errors: Terminated: 会话已经终止 Token: Invalid: 会话令牌是无效的 + Passkey: + NoChallenge: 没有密码挑战的会话 Intent: IDPMissing: 请求中缺少IDP ID SuccessURLMissing: 请求中缺少成功URL diff --git a/internal/webauthn/client.go b/internal/webauthn/client.go index ac7fbe766..511378fee 100644 --- a/internal/webauthn/client.go +++ b/internal/webauthn/client.go @@ -4,6 +4,8 @@ import ( "fmt" "github.com/descope/virtualwebauthn" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/structpb" ) type Client struct { @@ -25,12 +27,40 @@ func NewClient(name, domain, origin string) *Client { } } -func (c *Client) CreateAttestationResponse(options []byte) ([]byte, error) { +func (c *Client) CreateAttestationResponse(optionsPb *structpb.Struct) (*structpb.Struct, error) { + options, err := protojson.Marshal(optionsPb) + if err != nil { + return nil, fmt.Errorf("webauthn.Client.CreateAttestationResponse: %w", err) + } parsedAttestationOptions, err := virtualwebauthn.ParseAttestationOptions(string(options)) if err != nil { return nil, fmt.Errorf("webauthn.Client.CreateAttestationResponse: %w", err) } - return []byte(virtualwebauthn.CreateAttestationResponse( + resp := new(structpb.Struct) + err = protojson.Unmarshal([]byte(virtualwebauthn.CreateAttestationResponse( c.rp, c.auth, c.credential, *parsedAttestationOptions, - )), nil + )), resp) + if err != nil { + return nil, fmt.Errorf("webauthn.Client.CreateAttestationResponse: %w", err) + } + return resp, nil +} + +func (c *Client) CreateAssertionResponse(optionsPb *structpb.Struct) (*structpb.Struct, error) { + options, err := protojson.Marshal(optionsPb) + if err != nil { + return nil, fmt.Errorf("webauthn.Client.CreateAssertionResponse: %w", err) + } + parsedAssertionOptions, err := virtualwebauthn.ParseAssertionOptions(string(options)) + if err != nil { + return nil, fmt.Errorf("webauthn.Client.CreateAssertionResponse: %w", err) + } + resp := new(structpb.Struct) + err = protojson.Unmarshal([]byte(virtualwebauthn.CreateAssertionResponse( + c.rp, c.auth, c.credential, *parsedAssertionOptions, + )), resp) + if err != nil { + return nil, fmt.Errorf("webauthn.Client.CreateAssertionResponse: %w", err) + } + return resp, nil } diff --git a/proto/zitadel/session/v2alpha/challenge.proto b/proto/zitadel/session/v2alpha/challenge.proto new file mode 100644 index 000000000..498cb729b --- /dev/null +++ b/proto/zitadel/session/v2alpha/challenge.proto @@ -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; +} diff --git a/proto/zitadel/session/v2alpha/session.proto b/proto/zitadel/session/v2alpha/session.proto index 279863d14..79f203086 100644 --- a/proto/zitadel/session/v2alpha/session.proto +++ b/proto/zitadel/session/v2alpha/session.proto @@ -2,7 +2,6 @@ syntax = "proto3"; package zitadel.session.v2alpha; -import "google/api/field_behavior.proto"; import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; @@ -45,6 +44,7 @@ message Session { message Factors { UserFactor user = 1; PasswordFactor password = 2; + PasskeyFactor passkey = 3; } message UserFactor { @@ -78,6 +78,14 @@ message PasswordFactor { ]; } +message PasskeyFactor { + google.protobuf.Timestamp verified_at = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when the passkey challenge was last checked\""; + } + ]; +} + message SearchQuery { oneof query { option (validate.required) = true; diff --git a/proto/zitadel/session/v2alpha/session_service.proto b/proto/zitadel/session/v2alpha/session_service.proto index e56462107..0ee4b05d3 100644 --- a/proto/zitadel/session/v2alpha/session_service.proto +++ b/proto/zitadel/session/v2alpha/session_service.proto @@ -5,9 +5,11 @@ package zitadel.session.v2alpha; import "zitadel/object/v2alpha/object.proto"; import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/session/v2alpha/challenge.proto"; import "zitadel/session/v2alpha/session.proto"; import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; +import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; @@ -242,6 +244,7 @@ message CreateSessionRequest{ description: "\"custom key value list to be stored on the session\""; } ]; + repeated ChallengeKind challenges = 3; } message CreateSessionResponse{ @@ -257,6 +260,7 @@ message CreateSessionResponse{ description: "\"token of the session, which is required for further updates of the session or the request other resources\""; } ]; + Challenges challenges = 4; } message SetSessionRequest{ @@ -287,6 +291,7 @@ message SetSessionRequest{ description: "\"custom key value list to be stored on the session\""; } ]; + repeated ChallengeKind challenges = 5; } message SetSessionResponse{ @@ -296,6 +301,7 @@ message SetSessionResponse{ description: "\"token of the session, which is required for further updates of the session or the request other resources\""; } ]; + Challenges challenges = 3; } message DeleteSessionRequest{ @@ -330,6 +336,11 @@ message Checks { description: "\"Checks the password and updates the session on success. Requires that the user is already checked, either in the previous or the same request.\""; } ]; + optional CheckPasskey passkey = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Checks the public key credential issued by the passkey client. Requires that the user is already checked and a passkey challenge to be requested, in any previous request.\""; + } + ]; } message CheckUser { @@ -363,3 +374,15 @@ message CheckPassword { } ]; } + +message CheckPasskey { + google.protobuf.Struct credential_assertion_data = 1 [ + (validate.rules).message.required = true, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "JSON representation of public key credential issued by the passkey client"; + min_length: 55; + max_length: 1048576; //1 MB + } + ]; +} diff --git a/proto/zitadel/user/v2alpha/user_service.proto b/proto/zitadel/user/v2alpha/user_service.proto index 1af446f09..9522cd96a 100644 --- a/proto/zitadel/user/v2alpha/user_service.proto +++ b/proto/zitadel/user/v2alpha/user_service.proto @@ -12,6 +12,7 @@ import "zitadel/user/v2alpha/user.proto"; import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; import "validate/validate.proto"; @@ -429,10 +430,10 @@ message RegisterPasskeyResponse{ example: "\"fabde5c8-c13f-481d-a90b-5e59a001a076\"" } ]; - bytes public_key_credential_creation_options = 3 [ + google.protobuf.Struct public_key_credential_creation_options = 3 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "json representation of public key credential creation options used by the passkey client" - example: "\"eyJwdWJsaWNLZXkiOnsiY2hhbGxlbmdlIoplfZm4vM21qSzBPdjltN2x6VWhnclYyejFJSlVzZnpLd0Z1TytWTWtzRW1Icz0iLCJycCI6eyJuYW1lIjoiWklUQURFTCIsImlkIjoiYWNtZS1nem9lNHgueml0YWRlbC5jbG91ZCJ9LCJ1c2VyIjp7Im5hbWUiOiJ0ZXN0dXNlcjU1QGFjbWUueml0YWRlbC5jbG91ZCIsImRpc3BsYXlOYW1lIjoiVGVzdCBUZXN0IiwiaWQiOiJNVGd5TVRVMk1qWTBNakk1TXpBMk5qSTEifSwicHViS2V5Q3JlZFBhcmFtcyI6W3sidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi03fSx7InR5cGUiOiJwdWJsaWMta2V5IiwiYWxnIjotMzV9LHsidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi0zNn0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTI1N30seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTI1OH0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTI1OX0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTM3fSx7InR5cGUiOiJwdWJsaWMta2V5IiwiYWxnIjotMzh9LHsidHlwZSI6InB1YmxpYy1rZXkiLCJhbGciOi0zOX0seyJ0eXBlIjoicHVibGljLWtleSIsImFsZyI6LTh9XSwiYXV0aGVudGljYXRvclNlbGVjdGlvbiI6eyJ1c2VyVmVyaWZpY2F0aW9uIjoiZGlzY291cmFnZWQifn2ilGltZW91dCI6NjAwMDAsImF0dGVzdGF0aW9uIjoibm9uZSJ9fQ==\"" + description: "Options for Credential Creation (dictionary PublicKeyCredentialCreationOptions). Generated helper methods transform the field to JSON, for use in a WebauthN client. See also: https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialcreationoptions" + example: "{\"publicKey\":{\"attestation\":\"none\",\"authenticatorSelection\":{\"userVerification\":\"required\"},\"challenge\":\"XaMYwWOZ5hj6pwtwJJlpcI-ExkO5TxevBMG4R8DoKQQ\",\"excludeCredentials\":[{\"id\":\"tVp1QfYhT8DkyEHVrv7blnpAo2YJzbZgZNBf7zPs6CI\",\"type\":\"public-key\"}],\"pubKeyCredParams\":[{\"alg\":-7,\"type\":\"public-key\"}],\"rp\":{\"id\":\"localhost\",\"name\":\"ZITADEL\"},\"timeout\":300000,\"user\":{\"displayName\":\"Tim Mohlmann\",\"id\":\"MjE1NTk4MDAwNDY0OTk4OTQw\",\"name\":\"tim\"}}}" } ]; } @@ -456,11 +457,12 @@ message VerifyPasskeyRegistrationRequest{ example: "\"fabde5c8-c13f-481d-a90b-5e59a001a076\""; } ]; - bytes public_key_credential = 3 [ - (validate.rules).bytes = {min_len: 55, max_len: 1048576}, + google.protobuf.Struct public_key_credential = 3 [ + (validate.rules).message.required = true, (google.api.field_behavior) = REQUIRED, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - description: "JSON representation of public key credential issued by the passkey client"; + description: "PublicKeyCredential Interface. Generated helper methods populate the field from JSON created by a WebauthN client. See also: https://www.w3.org/TR/webauthn/#publickeycredential"; + example: "{\"type\":\"public-key\",\"id\":\"pawVarF4xPxLFmfCnRkwXWeTrKGzabcAi92LEI1WC00\",\"rawId\":\"pawVarF4xPxLFmfCnRkwXWeTrKGzabcAi92LEI1WC00\",\"response\":{\"attestationObject\":\"o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEcwRQIgRKS3VpeE9tfExXRzkoUKnG4rQWPvtSSt4YtDGgTx32oCIQDPey-2YJ4uIg-QCM4jj6aE2U3tgMFM_RP7Efx6xRu3JGhhdXRoRGF0YVikSZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAADju76085Yhmlt1CEOHkwLQAIKWsFWqxeMT8SxZnwp0ZMF1nk6yhs2m3AIvdixCNVgtNpQECAyYgASFYIMGUDSP2FAQn2MIfPMy7cyB_Y30VqixVgGULTBtFjfRiIlggjUGfQo3_-CrMmH3S-ZQkFKWKnNBQEAMkFtG-9A4zqW0\",\"clientDataJSON\":\"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiQlhXdHh0WGxJeFZZa0pHT1dVaUVmM25zby02aXZKdWw2YmNmWHdMVlFIayIsIm9yaWdpbiI6Imh0dHBzOi8vbG9jYWxob3N0OjgwODAifQ\"}}"; min_length: 55; max_length: 1048576; //1 MB }