From 1b5d6ce89ecbbc03d8e54fc7890fb8618f720a4f Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 21 Jun 2023 16:06:18 +0200 Subject: [PATCH] feat: session checks with intent (#6031) * feat: session checks with intent * feat: session checks with intent * fix: integration tests for intent session * fix: integration tests for intent session * fix merge * fix: integration tests for intent session --------- Co-authored-by: Livio Spring --- internal/api/grpc/session/v2/session.go | 13 ++ .../session/v2/session_integration_test.go | 121 ++++++++++- internal/api/grpc/user/v2/user.go | 17 +- .../api/grpc/user/v2/user_integration_test.go | 73 +------ internal/command/idp_intent.go | 5 +- internal/command/idp_intent_test.go | 33 ++- internal/command/session.go | 39 ++++ internal/command/session_model.go | 12 ++ internal/command/session_test.go | 202 ++++++++++++++++++ internal/crypto/crypto.go | 19 ++ internal/integration/client.go | 79 +++++++ internal/query/projection/session.go | 28 ++- internal/query/projection/session_test.go | 48 ++++- internal/query/session.go | 17 ++ internal/query/sessions_test.go | 82 ++++--- internal/repository/idpintent/intent.go | 4 +- internal/repository/session/eventstore.go | 1 + internal/repository/session/session.go | 42 ++++ internal/static/i18n/bg.yaml | 2 + internal/static/i18n/de.yaml | 1 + internal/static/i18n/en.yaml | 1 + internal/static/i18n/es.yaml | 1 + internal/static/i18n/fr.yaml | 1 + internal/static/i18n/it.yaml | 1 + internal/static/i18n/ja.yaml | 1 + internal/static/i18n/pl.yaml | 1 + internal/static/i18n/zh.yaml | 1 + proto/zitadel/session/v2alpha/session.proto | 9 + .../session/v2alpha/session_service.proto | 26 +++ 29 files changed, 727 insertions(+), 153 deletions(-) diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index f4af6d99b..6c31fbeab 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -119,6 +119,7 @@ func factorsToPb(s *query.Session) *session.Factors { User: user, Password: passwordFactorToPb(s.PasswordFactor), Passkey: passkeyFactorToPb(s.PasskeyFactor), + Intent: intentFactorToPb(s.IntentFactor), } } @@ -131,6 +132,15 @@ func passwordFactorToPb(factor query.SessionPasswordFactor) *session.PasswordFac } } +func intentFactorToPb(factor query.SessionIntentFactor) *session.IntentFactor { + if factor.IntentCheckedAt.IsZero() { + return nil + } + return &session.IntentFactor{ + VerifiedAt: timestamppb.New(factor.IntentCheckedAt), + } +} + func passkeyFactorToPb(factor query.SessionPasskeyFactor) *session.PasskeyFactor { if factor.PasskeyCheckedAt.IsZero() { return nil @@ -229,6 +239,9 @@ func (s *Server) checksToCommand(ctx context.Context, checks *session.Checks) ([ if password := checks.GetPassword(); password != nil { sessionChecks = append(sessionChecks, command.CheckPassword(password.GetPassword())) } + if intent := checks.GetIntent(); intent != nil { + sessionChecks = append(sessionChecks, command.CheckIntent(intent.GetIntentId(), intent.GetToken())) + } if passkey := checks.GetPasskey(); passkey != nil { sessionChecks = append(sessionChecks, s.command.CheckPasskey(passkey.GetCredentialAssertionData())) } diff --git a/internal/api/grpc/session/v2/session_integration_test.go b/internal/api/grpc/session/v2/session_integration_test.go index 1b3e6d19e..097bba7be 100644 --- a/internal/api/grpc/session/v2/session_integration_test.go +++ b/internal/api/grpc/session/v2/session_integration_test.go @@ -10,19 +10,21 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "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 + CTX context.Context + Tester *integration.Tester + Client session.SessionServiceClient + User *user.AddHumanUserResponse + GenericOAuthIDPID string ) func TestMain(m *testing.M) { @@ -82,6 +84,7 @@ const ( wantUserFactor wantFactor = iota wantPasswordFactor wantPasskeyFactor + wantIntentFactor ) func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, want []wantFactor) { @@ -100,6 +103,10 @@ func verifyFactors(t testing.TB, factors *session.Factors, window time.Duration, pf := factors.GetPasskey() assert.NotNil(t, pf) assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) + case wantIntentFactor: + pf := factors.GetIntent() + assert.NotNil(t, pf) + assert.WithinRange(t, pf.GetVerifiedAt().AsTime(), time.Now().Add(-window), time.Now().Add(window)) } } } @@ -212,6 +219,108 @@ func TestServer_CreateSession_passkey(t *testing.T) { verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantPasskeyFactor) } +func TestServer_CreateSession_successfulIntent(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t) + + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil) + + intentID, token, _, _ := Tester.CreateSuccessfulIntent(t, idpID, User.GetUserId(), "id") + updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: createResp.GetSessionToken(), + Checks: &session.Checks{ + Intent: &session.CheckIntent{ + IntentId: intentID, + Token: token, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantIntentFactor) +} + +func TestServer_CreateSession_successfulIntentUnknownUserID(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t) + + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil) + + idpUserID := "id" + intentID, token, _, _ := Tester.CreateSuccessfulIntent(t, idpID, "", idpUserID) + updateResp, err := Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: createResp.GetSessionToken(), + Checks: &session.Checks{ + Intent: &session.CheckIntent{ + IntentId: intentID, + Token: token, + }, + }, + }) + require.Error(t, err) + Tester.CreateUserIDPlink(CTX, User.GetUserId(), idpUserID, idpID, User.GetUserId()) + updateResp, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: createResp.GetSessionToken(), + Checks: &session.Checks{ + Intent: &session.CheckIntent{ + IntentId: intentID, + Token: token, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), updateResp.GetSessionToken(), updateResp.GetDetails().GetSequence(), time.Minute, nil, wantUserFactor, wantIntentFactor) +} + +func TestServer_CreateSession_startedIntentFalseToken(t *testing.T) { + idpID := Tester.AddGenericOAuthProvider(t) + + createResp, err := Client.CreateSession(CTX, &session.CreateSessionRequest{ + Checks: &session.Checks{ + User: &session.CheckUser{ + Search: &session.CheckUser_UserId{ + UserId: User.GetUserId(), + }, + }, + }, + }) + require.NoError(t, err) + verifyCurrentSession(t, createResp.GetSessionId(), createResp.GetSessionToken(), createResp.GetDetails().GetSequence(), time.Minute, nil) + + intentID := Tester.CreateIntent(t, idpID) + _, err = Client.SetSession(CTX, &session.SetSessionRequest{ + SessionId: createResp.GetSessionId(), + SessionToken: createResp.GetSessionToken(), + Checks: &session.Checks{ + Intent: &session.CheckIntent{ + IntentId: intentID, + Token: "false", + }, + }, + }) + require.Error(t, err) +} + func TestServer_SetSession_flow(t *testing.T) { var wantFactors []wantFactor diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 9969648c1..9e28b5029 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -2,7 +2,6 @@ package user import ( "context" - "encoding/base64" "io" "golang.org/x/text/language" @@ -205,21 +204,7 @@ func intentToIDPInformationPb(intent *command.IDPIntentWriteModel, alg crypto.En } func (s *Server) checkIntentToken(token string, intentID string) error { - if token == "" { - return errors.ThrowPermissionDenied(nil, "IDP-Sfefs", "Errors.Intent.InvalidToken") - } - data, err := base64.RawURLEncoding.DecodeString(token) - if err != nil { - return errors.ThrowPermissionDenied(err, "IDP-Swg31", "Errors.Intent.InvalidToken") - } - decryptedToken, err := s.idpAlg.Decrypt(data, s.idpAlg.EncryptionKeyID()) - if err != nil { - return errors.ThrowPermissionDenied(err, "IDP-Sf4gt", "Errors.Intent.InvalidToken") - } - if string(decryptedToken) != intentID { - return errors.ThrowPermissionDenied(nil, "IDP-dkje3", "Errors.Intent.InvalidToken") - } - return nil + return crypto.CheckToken(s.idpAlg, token, intentID) } func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) { diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index c30057524..40efe6195 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -13,17 +13,11 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/zitadel/oidc/v2/pkg/oidc" - "golang.org/x/oauth2" "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" - "github.com/zitadel/zitadel/internal/command" - openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" "github.com/zitadel/zitadel/internal/integration" - "github.com/zitadel/zitadel/internal/repository/idp" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" user "github.com/zitadel/zitadel/pkg/grpc/user/v2alpha" @@ -50,63 +44,8 @@ func TestMain(m *testing.M) { }()) } -func createProvider(t *testing.T) string { - ctx := authz.WithInstance(context.Background(), Tester.Instance) - id, _, err := Tester.Commands.AddOrgGenericOAuthProvider(ctx, Tester.Organisation.ID, command.GenericOAuthProvider{ - "idp", - "clientID", - "clientSecret", - "https://example.com/oauth/v2/authorize", - "https://example.com/oauth/v2/token", - "https://api.example.com/user", - []string{"openid", "profile", "email"}, - "id", - idp.Options{ - IsLinkingAllowed: true, - IsCreationAllowed: true, - IsAutoCreation: true, - IsAutoUpdate: true, - }, - }) - require.NoError(t, err) - return id -} - -func createIntent(t *testing.T, idpID string) string { - ctx := authz.WithInstance(context.Background(), Tester.Instance) - id, _, err := Tester.Commands.CreateIntent(ctx, idpID, "https://example.com/success", "https://example.com/failure", Tester.Organisation.ID) - require.NoError(t, err) - return id -} - -func createSuccessfulIntent(t *testing.T, idpID string) (string, string, time.Time, uint64) { - ctx := authz.WithInstance(context.Background(), Tester.Instance) - intentID := createIntent(t, idpID) - writeModel, err := Tester.Commands.GetIntentWriteModel(ctx, intentID, Tester.Organisation.ID) - require.NoError(t, err) - idpUser := openid.NewUser( - &oidc.UserInfo{ - Subject: "id", - UserInfoProfile: oidc.UserInfoProfile{ - PreferredUsername: "username", - }, - }, - ) - idpSession := &openid.Session{ - Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ - Token: &oauth2.Token{ - AccessToken: "accessToken", - }, - IDToken: "idToken", - }, - } - token, err := Tester.Commands.SucceedIDPIntent(ctx, writeModel, idpUser, idpSession, "") - require.NoError(t, err) - return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence -} - func TestServer_AddHumanUser(t *testing.T) { - idpID := createProvider(t) + idpID := Tester.AddGenericOAuthProvider(t) type args struct { ctx context.Context req *user.AddHumanUserRequest @@ -483,7 +422,7 @@ func TestServer_AddHumanUser(t *testing.T) { } func TestServer_AddIDPLink(t *testing.T) { - idpID := createProvider(t) + idpID := Tester.AddGenericOAuthProvider(t) type args struct { ctx context.Context req *user.AddIDPLinkRequest @@ -563,7 +502,7 @@ func TestServer_AddIDPLink(t *testing.T) { } func TestServer_StartIdentityProviderFlow(t *testing.T) { - idpID := createProvider(t) + idpID := Tester.AddGenericOAuthProvider(t) type args struct { ctx context.Context req *user.StartIdentityProviderFlowRequest @@ -627,9 +566,9 @@ func TestServer_StartIdentityProviderFlow(t *testing.T) { } func TestServer_RetrieveIdentityProviderInformation(t *testing.T) { - idpID := createProvider(t) - intentID := createIntent(t, idpID) - successfulID, token, changeDate, sequence := createSuccessfulIntent(t, idpID) + idpID := Tester.AddGenericOAuthProvider(t) + intentID := Tester.CreateIntent(t, idpID) + successfulID, token, changeDate, sequence := Tester.CreateSuccessfulIntent(t, idpID, "", "id") type args struct { ctx context.Context req *user.RetrieveIdentityProviderInformationRequest diff --git a/internal/command/idp_intent.go b/internal/command/idp_intent.go index a6d64dd78..99db0323b 100644 --- a/internal/command/idp_intent.go +++ b/internal/command/idp_intent.go @@ -119,7 +119,7 @@ func (c *Commands) SucceedIDPIntent(ctx context.Context, writeModel *IDPIntentWr if err != nil { return "", err } - cmd, err := idpintent.NewSucceededEvent( + cmd := idpintent.NewSucceededEvent( ctx, &idpintent.NewAggregate(writeModel.AggregateID, writeModel.ResourceOwner).Aggregate, idpInfo, @@ -129,9 +129,6 @@ func (c *Commands) SucceedIDPIntent(ctx context.Context, writeModel *IDPIntentWr accessToken, idToken, ) - if err != nil { - return "", err - } err = c.pushAppendAndReduce(ctx, writeModel, cmd) if err != nil { return "", err diff --git a/internal/command/idp_intent_test.go b/internal/command/idp_intent_test.go index 3eb64cb59..08948a200 100644 --- a/internal/command/idp_intent_test.go +++ b/internal/command/idp_intent_test.go @@ -435,24 +435,21 @@ func TestCommands_SucceedIDPIntent(t *testing.T) { eventstore: eventstoreExpect(t, expectPush( eventPusherToEvents( - func() eventstore.Command { - event, _ := idpintent.NewSucceededEvent( - context.Background(), - &idpintent.NewAggregate("id", "ro").Aggregate, - []byte(`{"sub":"id","preferred_username":"username"}`), - "id", - "username", - "", - &crypto.CryptoValue{ - CryptoType: crypto.TypeEncryption, - Algorithm: "enc", - KeyID: "id", - Crypted: []byte("accessToken"), - }, - "idToken", - ) - return event - }(), + idpintent.NewSucceededEvent( + context.Background(), + &idpintent.NewAggregate("id", "ro").Aggregate, + []byte(`{"sub":"id","preferred_username":"username"}`), + "id", + "username", + "", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("accessToken"), + }, + "idToken", + ), ), ), ), diff --git a/internal/command/session.go b/internal/command/session.go index 2feac20e0..71e9a6982 100644 --- a/internal/command/session.go +++ b/internal/command/session.go @@ -23,8 +23,10 @@ type SessionCommands struct { sessionWriteModel *SessionWriteModel passwordWriteModel *HumanPasswordWriteModel + intentWriteModel *IDPIntentWriteModel eventstore *eventstore.Eventstore userPasswordAlg crypto.HashAlgorithm + intentAlg crypto.EncryptionAlgorithm createToken func(sessionID string) (id string, token string, err error) now func() time.Time } @@ -35,6 +37,7 @@ func (c *Commands) NewSessionCommands(cmds []SessionCommand, session *SessionWri sessionWriteModel: session, eventstore: c.eventstore, userPasswordAlg: c.userPasswordAlg, + intentAlg: c.idpConfigEncryption, createToken: c.sessionTokenCreator, now: time.Now, } @@ -80,6 +83,42 @@ func CheckPassword(password string) SessionCommand { } } +// CheckIntent defines a check for a succeeded intent to be executed for a session update +func CheckIntent(intentID, token string) SessionCommand { + return func(ctx context.Context, cmd *SessionCommands) error { + if cmd.sessionWriteModel.UserID == "" { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Sfw3r", "Errors.User.UserIDMissing") + } + if err := crypto.CheckToken(cmd.intentAlg, token, intentID); err != nil { + return err + } + cmd.intentWriteModel = NewIDPIntentWriteModel(intentID, "") + err := cmd.eventstore.FilterToQueryReducer(ctx, cmd.intentWriteModel) + if err != nil { + return err + } + if cmd.intentWriteModel.State != domain.IDPIntentStateSucceeded { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4bw", "Errors.Intent.NotSucceeded") + } + if cmd.intentWriteModel.UserID != "" { + if cmd.intentWriteModel.UserID != cmd.sessionWriteModel.UserID { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-O8xk3w", "Errors.Intent.OtherUser") + } + } else { + linkWriteModel := NewUserIDPLinkWriteModel(cmd.sessionWriteModel.UserID, cmd.intentWriteModel.IDPID, cmd.intentWriteModel.IDPUserID, cmd.intentWriteModel.ResourceOwner) + err := cmd.eventstore.FilterToQueryReducer(ctx, linkWriteModel) + if err != nil { + return err + } + if linkWriteModel.State != domain.UserIDPLinkStateActive { + return caos_errs.ThrowPreconditionFailed(nil, "COMMAND-O8xk3w", "Errors.Intent.OtherUser") + } + } + cmd.sessionWriteModel.IntentChecked(ctx, cmd.now()) + return 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 { diff --git a/internal/command/session_model.go b/internal/command/session_model.go index 24edbfd2e..99fd9f36e 100644 --- a/internal/command/session_model.go +++ b/internal/command/session_model.go @@ -38,6 +38,7 @@ type SessionWriteModel struct { UserID string UserCheckedAt time.Time PasswordCheckedAt time.Time + IntentCheckedAt time.Time PasskeyCheckedAt time.Time Metadata map[string][]byte State domain.SessionState @@ -68,6 +69,8 @@ func (wm *SessionWriteModel) Reduce() error { wm.reduceUserChecked(e) case *session.PasswordCheckedEvent: wm.reducePasswordChecked(e) + case *session.IntentCheckedEvent: + wm.reduceIntentChecked(e) case *session.PasskeyChallengedEvent: wm.reducePasskeyChallenged(e) case *session.PasskeyCheckedEvent: @@ -90,6 +93,7 @@ func (wm *SessionWriteModel) Query() *eventstore.SearchQueryBuilder { session.AddedType, session.UserCheckedType, session.PasswordCheckedType, + session.IntentCheckedType, session.PasskeyChallengedType, session.PasskeyCheckedType, session.TokenSetType, @@ -117,6 +121,10 @@ func (wm *SessionWriteModel) reducePasswordChecked(e *session.PasswordCheckedEve wm.PasswordCheckedAt = e.CheckedAt } +func (wm *SessionWriteModel) reduceIntentChecked(e *session.IntentCheckedEvent) { + wm.IntentCheckedAt = e.CheckedAt +} + func (wm *SessionWriteModel) reducePasskeyChallenged(e *session.PasskeyChallengedEvent) { wm.PasskeyChallenge = &PasskeyChallengeModel{ Challenge: e.Challenge, @@ -153,6 +161,10 @@ func (wm *SessionWriteModel) PasswordChecked(ctx context.Context, checkedAt time wm.commands = append(wm.commands, session.NewPasswordCheckedEvent(ctx, wm.aggregate, checkedAt)) } +func (wm *SessionWriteModel) IntentChecked(ctx context.Context, checkedAt time.Time) { + wm.commands = append(wm.commands, session.NewIntentCheckedEvent(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)) } diff --git a/internal/command/session_test.go b/internal/command/session_test.go index b9e24b03e..f9287a772 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -18,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/id" "github.com/zitadel/zitadel/internal/id/mock" + "github.com/zitadel/zitadel/internal/repository/idpintent" "github.com/zitadel/zitadel/internal/repository/session" "github.com/zitadel/zitadel/internal/repository/user" ) @@ -341,6 +342,19 @@ func TestCommands_UpdateSession(t *testing.T) { } func TestCommands_updateSession(t *testing.T) { + decryption := func(err error) crypto.EncryptionAlgorithm { + mCrypto := crypto.NewMockEncryptionAlgorithm(gomock.NewController(t)) + mCrypto.EXPECT().EncryptionKeyID().Return("id") + mCrypto.EXPECT().DecryptString(gomock.Any(), gomock.Any()).DoAndReturn( + func(code []byte, keyID string) (string, error) { + if err != nil { + return "", err + } + return string(code), nil + }) + return mCrypto + } + testNow := time.Now() type fields struct { eventstore *eventstore.Eventstore @@ -484,6 +498,194 @@ func TestCommands_updateSession(t *testing.T) { }, }, }, + { + "set user, intent not successful", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + checks: &SessionCommands{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + cmds: []SessionCommand{ + CheckUser("userID"), + CheckIntent("intent", "aW50ZW50"), + }, + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + "username", "", "", "", "", language.English, domain.GenderUnspecified, "", false), + ), + ), + ), + createToken: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + intentAlg: decryption(nil), + now: func() time.Time { + return testNow + }, + }, + metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-Df4bw", "Errors.Intent.NotSucceeded"), + }, + }, + { + "set user, intent not for user", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + checks: &SessionCommands{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + cmds: []SessionCommand{ + CheckUser("userID"), + CheckIntent("intent", "aW50ZW50"), + }, + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + "username", "", "", "", "", language.English, domain.GenderUnspecified, "", false), + ), + eventFromEventPusher( + idpintent.NewSucceededEvent(context.Background(), &idpintent.NewAggregate("intent", "org1").Aggregate, + nil, + "idpUserID", + "idpUserName", + "userID2", + nil, + "", + ), + ), + ), + ), + createToken: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + intentAlg: decryption(nil), + now: func() time.Time { + return testNow + }, + }, + metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + res{ + err: caos_errs.ThrowPreconditionFailed(nil, "COMMAND-O8xk3w", "Errors.Intent.OtherUser"), + }, + }, + { + "set user, intent incorrect token", + fields{ + eventstore: eventstoreExpect(t), + }, + args{ + ctx: context.Background(), + checks: &SessionCommands{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + cmds: []SessionCommand{ + CheckUser("userID"), + CheckIntent("intent2", "aW50ZW50"), + }, + eventstore: eventstoreExpect(t), + createToken: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + intentAlg: decryption(nil), + now: func() time.Time { + return testNow + }, + }, + metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + res{ + err: caos_errs.ThrowPermissionDenied(nil, "CRYPTO-CRYPTO", "Errors.Intent.InvalidToken"), + }, + }, + { + "set user, intent, metadata and token", + fields{ + eventstore: eventstoreExpect(t, + expectPush( + eventPusherToEvents( + session.NewUserCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "userID", testNow), + session.NewIntentCheckedEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + testNow), + session.NewMetadataSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + map[string][]byte{"key": []byte("value")}), + session.NewTokenSetEvent(context.Background(), &session.NewAggregate("sessionID", "org1").Aggregate, + "tokenID"), + ), + ), + ), + }, + args{ + ctx: context.Background(), + checks: &SessionCommands{ + sessionWriteModel: NewSessionWriteModel("sessionID", "org1"), + cmds: []SessionCommand{ + CheckUser("userID"), + CheckIntent("intent", "aW50ZW50"), + }, + eventstore: eventstoreExpect(t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, + "username", "", "", "", "", language.English, domain.GenderUnspecified, "", false), + ), + eventFromEventPusher( + idpintent.NewSucceededEvent(context.Background(), &idpintent.NewAggregate("intent", "org1").Aggregate, + nil, + "idpUserID", + "idpUsername", + "userID", + nil, + "", + ), + ), + ), + ), + createToken: func(sessionID string) (string, string, error) { + return "tokenID", + "token", + nil + }, + intentAlg: decryption(nil), + now: func() time.Time { + return testNow + }, + }, + metadata: map[string][]byte{ + "key": []byte("value"), + }, + }, + res{ + want: &SessionChanged{ + ObjectDetails: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + ID: "sessionID", + NewToken: "token", + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 9799faea6..748a124b5 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -2,6 +2,7 @@ package crypto import ( "database/sql/driver" + "encoding/base64" "encoding/json" "github.com/zitadel/zitadel/internal/errors" @@ -132,3 +133,21 @@ func FillHash(value []byte, alg HashAlgorithm) *CryptoValue { Crypted: value, } } + +func CheckToken(alg EncryptionAlgorithm, token string, content string) error { + if token == "" { + return errors.ThrowPermissionDenied(nil, "CRYPTO-Sfefs", "Errors.Intent.InvalidToken") + } + data, err := base64.RawURLEncoding.DecodeString(token) + if err != nil { + return errors.ThrowPermissionDenied(err, "CRYPTO-Swg31", "Errors.Intent.InvalidToken") + } + decryptedToken, err := alg.DecryptString(data, alg.EncryptionKeyID()) + if err != nil { + return errors.ThrowPermissionDenied(err, "CRYPTO-Sf4gt", "Errors.Intent.InvalidToken") + } + if decryptedToken != content { + return errors.ThrowPermissionDenied(nil, "CRYPTO-CRYPTO", "Errors.Intent.InvalidToken") + } + return nil +} diff --git a/internal/integration/client.go b/internal/integration/client.go index 8f64936e0..f66db5b2a 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -3,11 +3,19 @@ package integration import ( "context" "fmt" + "testing" "time" + "github.com/stretchr/testify/require" "github.com/zitadel/logging" + "github.com/zitadel/oidc/v2/pkg/oidc" + "golang.org/x/oauth2" "google.golang.org/grpc" + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + openid "github.com/zitadel/zitadel/internal/idp/providers/oidc" + "github.com/zitadel/zitadel/internal/repository/idp" "github.com/zitadel/zitadel/pkg/grpc/admin" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2alpha" @@ -55,6 +63,22 @@ func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse return resp } +func (s *Tester) CreateUserIDPlink(ctx context.Context, userID, externalID, idpID, username string) *user.AddIDPLinkResponse { + resp, err := s.Client.UserV2.AddIDPLink( + ctx, + &user.AddIDPLinkRequest{ + UserId: userID, + IdpLink: &user.IDPLink{ + IdpId: idpID, + UserId: externalID, + UserName: username, + }, + }, + ) + logging.OnError(err).Fatal("create human user link") + return resp +} + func (s *Tester) RegisterUserPasskey(ctx context.Context, userID string) { reg, err := s.Client.UserV2.CreatePasskeyRegistrationLink(ctx, &user.CreatePasskeyRegistrationLinkRequest{ UserId: userID, @@ -78,3 +102,58 @@ func (s *Tester) RegisterUserPasskey(ctx context.Context, userID string) { }) logging.OnError(err).Fatal("create user passkey") } + +func (s *Tester) AddGenericOAuthProvider(t *testing.T) string { + ctx := authz.WithInstance(context.Background(), s.Instance) + id, _, err := s.Commands.AddOrgGenericOAuthProvider(ctx, s.Organisation.ID, command.GenericOAuthProvider{ + Name: "idp", + ClientID: "clientID", + ClientSecret: "clientSecret", + AuthorizationEndpoint: "https://example.com/oauth/v2/authorize", + TokenEndpoint: "https://example.com/oauth/v2/token", + UserEndpoint: "https://api.example.com/user", + Scopes: []string{"openid", "profile", "email"}, + IDAttribute: "id", + IDPOptions: idp.Options{ + IsLinkingAllowed: true, + IsCreationAllowed: true, + IsAutoCreation: true, + IsAutoUpdate: true, + }, + }) + require.NoError(t, err) + return id +} + +func (s *Tester) CreateIntent(t *testing.T, idpID string) string { + ctx := authz.WithInstance(context.Background(), s.Instance) + id, _, err := s.Commands.CreateIntent(ctx, idpID, "https://example.com/success", "https://example.com/failure", s.Organisation.ID) + require.NoError(t, err) + return id +} + +func (s *Tester) CreateSuccessfulIntent(t *testing.T, idpID, userID, idpUserID string) (string, string, time.Time, uint64) { + ctx := authz.WithInstance(context.Background(), s.Instance) + intentID := s.CreateIntent(t, idpID) + writeModel, err := s.Commands.GetIntentWriteModel(ctx, intentID, s.Organisation.ID) + require.NoError(t, err) + idpUser := openid.NewUser( + &oidc.UserInfo{ + Subject: idpUserID, + UserInfoProfile: oidc.UserInfoProfile{ + PreferredUsername: "username", + }, + }, + ) + idpSession := &openid.Session{ + Tokens: &oidc.Tokens[*oidc.IDTokenClaims]{ + Token: &oauth2.Token{ + AccessToken: "accessToken", + }, + IDToken: "idToken", + }, + } + token, err := s.Commands.SucceedIDPIntent(ctx, writeModel, idpUser, idpSession, userID) + require.NoError(t, err) + return intentID, token, writeModel.ChangeDate, writeModel.ProcessedSequence +} diff --git a/internal/query/projection/session.go b/internal/query/projection/session.go index a0f65a70a..dc31fd37a 100644 --- a/internal/query/projection/session.go +++ b/internal/query/projection/session.go @@ -14,7 +14,7 @@ import ( ) const ( - SessionsProjectionTable = "projections.sessions1" + SessionsProjectionTable = "projections.sessions2" SessionColumnID = "id" SessionColumnCreationDate = "creation_date" @@ -27,6 +27,7 @@ const ( SessionColumnUserID = "user_id" SessionColumnUserCheckedAt = "user_checked_at" SessionColumnPasswordCheckedAt = "password_checked_at" + SessionColumnIntentCheckedAt = "intent_checked_at" SessionColumnPasskeyCheckedAt = "passkey_checked_at" SessionColumnMetadata = "metadata" SessionColumnTokenID = "token_id" @@ -53,6 +54,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(SessionColumnIntentCheckedAt, 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()), @@ -81,6 +83,10 @@ func (p *sessionProjection) reducers() []handler.AggregateReducer { Event: session.PasswordCheckedType, Reduce: p.reducePasswordChecked, }, + { + Event: session.IntentCheckedType, + Reduce: p.reduceIntentChecked, + }, { Event: session.PasskeyCheckedType, Reduce: p.reducePasskeyChecked, @@ -181,6 +187,26 @@ func (p *sessionProjection) reducePasswordChecked(event eventstore.Event) (*hand ), nil } +func (p *sessionProjection) reduceIntentChecked(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*session.IntentCheckedEvent) + if !ok { + return nil, errors.ThrowInvalidArgumentf(nil, "HANDL-SDgr2", "reduce.wrong.event.type %s", session.IntentCheckedType) + } + + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SessionColumnChangeDate, e.CreationDate()), + handler.NewCol(SessionColumnSequence, e.Sequence()), + handler.NewCol(SessionColumnIntentCheckedAt, e.CheckedAt), + }, + []handler.Condition{ + handler.NewCond(SessionColumnID, e.Aggregate().ID), + handler.NewCond(SessionColumnInstanceID, e.Aggregate().InstanceID), + }, + ), nil +} + func (p *sessionProjection) reducePasskeyChecked(event eventstore.Event) (*handler.Statement, error) { e, ok := event.(*session.PasskeyCheckedEvent) if !ok { diff --git a/internal/query/projection/session_test.go b/internal/query/projection/session_test.go index 2e4a7b645..976eab8b7 100644 --- a/internal/query/projection/session_test.go +++ b/internal/query/projection/session_test.go @@ -41,7 +41,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - 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)", + expectedStmt: "INSERT INTO projections.sessions2 (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", @@ -77,7 +77,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)", + expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, user_id, user_checked_at) = ($1, $2, $3, $4) WHERE (id = $5) AND (instance_id = $6)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -110,7 +110,39 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, password_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedArgs: []interface{}{ + anyArg{}, + anyArg{}, + time.Date(2023, time.May, 4, 0, 0, 0, 0, time.UTC), + "agg-id", + "instance-id", + }, + }, + }, + }, + }, + }, + { + name: "instance reduceIntentChecked", + args: args{ + event: getEvent(testEvent( + session.AddedType, + session.AggregateType, + []byte(`{ + "checkedAt": "2023-05-04T00:00:00Z" + }`), + ), session.IntentCheckedEventMapper), + }, + reduce: (&sessionProjection{}).reduceIntentChecked, + want: wantReduce{ + aggregateType: eventstore.AggregateType("session"), + sequence: 15, + previousSequence: 10, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, intent_checked_at) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -142,7 +174,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, token_id) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -176,7 +208,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions1 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", + expectedStmt: "UPDATE projections.sessions2 SET (change_date, sequence, metadata) = ($1, $2, $3) WHERE (id = $4) AND (instance_id = $5)", expectedArgs: []interface{}{ anyArg{}, anyArg{}, @@ -208,7 +240,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.sessions1 WHERE (id = $1) AND (instance_id = $2)", + expectedStmt: "DELETE FROM projections.sessions2 WHERE (id = $1) AND (instance_id = $2)", expectedArgs: []interface{}{ "agg-id", "instance-id", @@ -235,7 +267,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "DELETE FROM projections.sessions1 WHERE (instance_id = $1)", + expectedStmt: "DELETE FROM projections.sessions2 WHERE (instance_id = $1)", expectedArgs: []interface{}{ "agg-id", }, @@ -266,7 +298,7 @@ func TestSessionProjection_reduces(t *testing.T) { executer: &testExecuter{ executions: []execution{ { - expectedStmt: "UPDATE projections.sessions1 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)", + expectedStmt: "UPDATE projections.sessions2 SET password_checked_at = $1 WHERE (user_id = $2) AND (password_checked_at < $3)", expectedArgs: []interface{}{ nil, "agg-id", diff --git a/internal/query/session.go b/internal/query/session.go index 2dfc622a7..427fc90f2 100644 --- a/internal/query/session.go +++ b/internal/query/session.go @@ -32,6 +32,7 @@ type Session struct { Creator string UserFactor SessionUserFactor PasswordFactor SessionPasswordFactor + IntentFactor SessionIntentFactor PasskeyFactor SessionPasskeyFactor Metadata map[string][]byte } @@ -47,6 +48,10 @@ type SessionPasswordFactor struct { PasswordCheckedAt time.Time } +type SessionIntentFactor struct { + IntentCheckedAt time.Time +} + type SessionPasskeyFactor struct { PasskeyCheckedAt time.Time } @@ -113,6 +118,10 @@ var ( name: projection.SessionColumnPasswordCheckedAt, table: sessionsTable, } + SessionColumnIntentCheckedAt = Column{ + name: projection.SessionColumnIntentCheckedAt, + table: sessionsTable, + } SessionColumnPasskeyCheckedAt = Column{ name: projection.SessionColumnPasskeyCheckedAt, table: sessionsTable, @@ -207,6 +216,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil LoginNameNameCol.identifier(), HumanDisplayNameCol.identifier(), SessionColumnPasswordCheckedAt.identifier(), + SessionColumnIntentCheckedAt.identifier(), SessionColumnPasskeyCheckedAt.identifier(), SessionColumnMetadata.identifier(), SessionColumnToken.identifier(), @@ -222,6 +232,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil loginName sql.NullString displayName sql.NullString passwordCheckedAt sql.NullTime + intentCheckedAt sql.NullTime passkeyCheckedAt sql.NullTime metadata database.Map[[]byte] token sql.NullString @@ -240,6 +251,7 @@ func prepareSessionQuery(ctx context.Context, db prepareDatabase) (sq.SelectBuil &loginName, &displayName, &passwordCheckedAt, + &intentCheckedAt, &passkeyCheckedAt, &metadata, &token, @@ -257,6 +269,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.IntentFactor.IntentCheckedAt = intentCheckedAt.Time session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time session.Metadata = metadata @@ -278,6 +291,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui LoginNameNameCol.identifier(), HumanDisplayNameCol.identifier(), SessionColumnPasswordCheckedAt.identifier(), + SessionColumnIntentCheckedAt.identifier(), SessionColumnPasskeyCheckedAt.identifier(), SessionColumnMetadata.identifier(), countColumn.identifier(), @@ -296,6 +310,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui loginName sql.NullString displayName sql.NullString passwordCheckedAt sql.NullTime + intentCheckedAt sql.NullTime passkeyCheckedAt sql.NullTime metadata database.Map[[]byte] ) @@ -313,6 +328,7 @@ func prepareSessionsQuery(ctx context.Context, db prepareDatabase) (sq.SelectBui &loginName, &displayName, &passwordCheckedAt, + &intentCheckedAt, &passkeyCheckedAt, &metadata, &sessions.Count, @@ -326,6 +342,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.IntentFactor.IntentCheckedAt = intentCheckedAt.Time session.PasskeyFactor.PasskeyCheckedAt = passkeyCheckedAt.Time session.Metadata = metadata diff --git a/internal/query/sessions_test.go b/internal/query/sessions_test.go index 03a5b2197..84990e2e1 100644 --- a/internal/query/sessions_test.go +++ b/internal/query/sessions_test.go @@ -17,43 +17,45 @@ import ( ) var ( - 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,` + + expectedSessionQuery = regexp.QuoteMeta(`SELECT projections.sessions2.id,` + + ` projections.sessions2.creation_date,` + + ` projections.sessions2.change_date,` + + ` projections.sessions2.sequence,` + + ` projections.sessions2.state,` + + ` projections.sessions2.resource_owner,` + + ` projections.sessions2.creator,` + + ` projections.sessions2.user_id,` + + ` projections.sessions2.user_checked_at,` + ` projections.login_names2.login_name,` + ` projections.users8_humans.display_name,` + - ` 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` + + ` projections.sessions2.password_checked_at,` + + ` projections.sessions2.intent_checked_at,` + + ` projections.sessions2.passkey_checked_at,` + + ` projections.sessions2.metadata,` + + ` projections.sessions2.token_id` + + ` FROM projections.sessions2` + + ` LEFT JOIN projections.login_names2 ON projections.sessions2.user_id = projections.login_names2.user_id AND projections.sessions2.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions2.user_id = projections.users8_humans.user_id AND projections.sessions2.instance_id = projections.users8_humans.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) - 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,` + + expectedSessionsQuery = regexp.QuoteMeta(`SELECT projections.sessions2.id,` + + ` projections.sessions2.creation_date,` + + ` projections.sessions2.change_date,` + + ` projections.sessions2.sequence,` + + ` projections.sessions2.state,` + + ` projections.sessions2.resource_owner,` + + ` projections.sessions2.creator,` + + ` projections.sessions2.user_id,` + + ` projections.sessions2.user_checked_at,` + ` projections.login_names2.login_name,` + ` projections.users8_humans.display_name,` + - ` projections.sessions1.password_checked_at,` + - ` projections.sessions1.passkey_checked_at,` + - ` projections.sessions1.metadata,` + + ` projections.sessions2.password_checked_at,` + + ` projections.sessions2.intent_checked_at,` + + ` projections.sessions2.passkey_checked_at,` + + ` projections.sessions2.metadata,` + ` COUNT(*) OVER ()` + - ` 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` + + ` FROM projections.sessions2` + + ` LEFT JOIN projections.login_names2 ON projections.sessions2.user_id = projections.login_names2.user_id AND projections.sessions2.instance_id = projections.login_names2.instance_id` + + ` LEFT JOIN projections.users8_humans ON projections.sessions2.user_id = projections.users8_humans.user_id AND projections.sessions2.instance_id = projections.users8_humans.instance_id` + ` AS OF SYSTEM TIME '-1 ms'`) sessionCols = []string{ @@ -69,6 +71,7 @@ var ( "login_name", "display_name", "password_checked_at", + "intent_checked_at", "passkey_checked_at", "metadata", "token", @@ -87,6 +90,7 @@ var ( "login_name", "display_name", "password_checked_at", + "intent_checked_at", "passkey_checked_at", "metadata", "count", @@ -138,6 +142,7 @@ func Test_SessionsPrepare(t *testing.T) { "display-name", testNow, testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), }, }, @@ -165,6 +170,9 @@ func Test_SessionsPrepare(t *testing.T) { PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, }, + IntentFactor: SessionIntentFactor{ + IntentCheckedAt: testNow, + }, PasskeyFactor: SessionPasskeyFactor{ PasskeyCheckedAt: testNow, }, @@ -197,6 +205,7 @@ func Test_SessionsPrepare(t *testing.T) { "display-name", testNow, testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), }, { @@ -213,6 +222,7 @@ func Test_SessionsPrepare(t *testing.T) { "display-name2", testNow, testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), }, }, @@ -240,6 +250,9 @@ func Test_SessionsPrepare(t *testing.T) { PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, }, + IntentFactor: SessionIntentFactor{ + IntentCheckedAt: testNow, + }, PasskeyFactor: SessionPasskeyFactor{ PasskeyCheckedAt: testNow, }, @@ -264,6 +277,9 @@ func Test_SessionsPrepare(t *testing.T) { PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, }, + IntentFactor: SessionIntentFactor{ + IntentCheckedAt: testNow, + }, PasskeyFactor: SessionPasskeyFactor{ PasskeyCheckedAt: testNow, }, @@ -349,6 +365,7 @@ func Test_SessionPrepare(t *testing.T) { "display-name", testNow, testNow, + testNow, []byte(`{"key": "dmFsdWU="}`), "tokenID", }, @@ -371,6 +388,9 @@ func Test_SessionPrepare(t *testing.T) { PasswordFactor: SessionPasswordFactor{ PasswordCheckedAt: testNow, }, + IntentFactor: SessionIntentFactor{ + IntentCheckedAt: testNow, + }, PasskeyFactor: SessionPasskeyFactor{ PasskeyCheckedAt: testNow, }, diff --git a/internal/repository/idpintent/intent.go b/internal/repository/idpintent/intent.go index 52036b8a7..fe26c9dfa 100644 --- a/internal/repository/idpintent/intent.go +++ b/internal/repository/idpintent/intent.go @@ -85,7 +85,7 @@ func NewSucceededEvent( userID string, idpAccessToken *crypto.CryptoValue, idpIDToken string, -) (*SucceededEvent, error) { +) *SucceededEvent { return &SucceededEvent{ BaseEvent: *eventstore.NewBaseEventForPush( ctx, @@ -98,7 +98,7 @@ func NewSucceededEvent( UserID: userID, IDPAccessToken: idpAccessToken, IDPIDToken: idpIDToken, - }, nil + } } func (e *SucceededEvent) Data() interface{} { diff --git a/internal/repository/session/eventstore.go b/internal/repository/session/eventstore.go index 3253cc856..07f458586 100644 --- a/internal/repository/session/eventstore.go +++ b/internal/repository/session/eventstore.go @@ -6,6 +6,7 @@ func RegisterEventMappers(es *eventstore.Eventstore) { es.RegisterFilterEventMapper(AggregateType, AddedType, AddedEventMapper). RegisterFilterEventMapper(AggregateType, UserCheckedType, UserCheckedEventMapper). RegisterFilterEventMapper(AggregateType, PasswordCheckedType, PasswordCheckedEventMapper). + RegisterFilterEventMapper(AggregateType, IntentCheckedType, IntentCheckedEventMapper). RegisterFilterEventMapper(AggregateType, PasskeyChallengedType, eventstore.GenericEventMapper[PasskeyChallengedEvent]). RegisterFilterEventMapper(AggregateType, PasskeyCheckedType, eventstore.GenericEventMapper[PasskeyCheckedEvent]). RegisterFilterEventMapper(AggregateType, TokenSetType, TokenSetEventMapper). diff --git a/internal/repository/session/session.go b/internal/repository/session/session.go index 887f056e8..c11a6eb45 100644 --- a/internal/repository/session/session.go +++ b/internal/repository/session/session.go @@ -16,6 +16,7 @@ const ( AddedType = sessionEventPrefix + "added" UserCheckedType = sessionEventPrefix + "user.checked" PasswordCheckedType = sessionEventPrefix + "password.checked" + IntentCheckedType = sessionEventPrefix + "intent.checked" PasskeyChallengedType = sessionEventPrefix + "passkey.challenged" PasskeyCheckedType = sessionEventPrefix + "passkey.checked" TokenSetType = sessionEventPrefix + "token.set" @@ -144,6 +145,47 @@ func PasswordCheckedEventMapper(event *repository.Event) (eventstore.Event, erro return added, nil } +type IntentCheckedEvent struct { + eventstore.BaseEvent `json:"-"` + + CheckedAt time.Time `json:"checkedAt"` +} + +func (e *IntentCheckedEvent) Data() interface{} { + return e +} + +func (e *IntentCheckedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func NewIntentCheckedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + checkedAt time.Time, +) *IntentCheckedEvent { + return &IntentCheckedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + IntentCheckedType, + ), + CheckedAt: checkedAt, + } +} + +func IntentCheckedEventMapper(event *repository.Event) (eventstore.Event, error) { + added := &IntentCheckedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, added) + if err != nil { + return nil, errors.ThrowInternal(err, "SESSION-DGt90", "unable to unmarshal intent checked") + } + + return added, nil +} + type PasskeyChallengedEvent struct { eventstore.BaseEvent `json:"-"` diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index e9d9510be..695007888 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -505,6 +505,8 @@ Errors: NotSucceeded: Намерението не е успешно TokenCreationFailed: Неуспешно създаване на токен InvalidToken: Знакът за намерение е невалиден + OtherUser: Намерение, предназначено за друг потребител + AggregateTypes: action: Действие instance: Инстанция diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 6d830b2de..846e09dff 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -487,6 +487,7 @@ Errors: NotSucceeded: Intent war nicht erfolgreich TokenCreationFailed: Tokenerstellung schlug fehl InvalidToken: Intent Token ist ungültig + OtherUser: Intent ist für anderen Benutzer gedacht AggregateTypes: action: Action diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 88763384e..5986651ea 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -487,6 +487,7 @@ Errors: NotSucceeded: Intent has not succeeded TokenCreationFailed: Token creation failed InvalidToken: Intent Token is invalid + OtherUser: Intent meant for another user AggregateTypes: action: Action diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index dbfa308c8..1adb10763 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -487,6 +487,7 @@ Errors: NotSucceeded: Intento fallido TokenCreationFailed: Fallo en la creación del token InvalidToken: El token de la intención no es válido + OtherUser: Destinado a otro usuario AggregateTypes: action: Acción diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 9d4c03ed6..5789a815c 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -487,6 +487,7 @@ Errors: NotSucceeded: l'intention n'a pas abouti TokenCreationFailed: La création du token a échoué InvalidToken: Le jeton d'intention n'est pas valide + OtherUser: Intention destinée à un autre utilisateur AggregateTypes: action: Action diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 113e99c30..4e51b0fc3 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -487,6 +487,7 @@ Errors: NotSucceeded: l'intento non è andato a buon fine TokenCreationFailed: creazione del token fallita InvalidToken: Il token dell'intento non è valido + OtherUser: Intento destinato a un altro utente AggregateTypes: action: Azione diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index fd2fd5abe..ed6d070d1 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -476,6 +476,7 @@ Errors: NotSucceeded: インテントが成功しなかった TokenCreationFailed: トークンの作成に失敗しました InvalidToken: インテントのトークンが無効である + OtherUser: 他のユーザーを意図している AggregateTypes: action: アクション diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 0c7e56ccd..9e907dcbe 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -487,6 +487,7 @@ Errors: NotSucceeded: intencja nie powiodła się TokenCreationFailed: Tworzenie tokena nie powiodło się InvalidToken: Token intencji jest nieprawidłowy + OtherUser: Intencja przeznaczona dla innego użytkownika AggregateTypes: action: Działanie diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 3a1508e05..2a9f2400f 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -487,6 +487,7 @@ Errors: NotSucceeded: 意图不成功 TokenCreationFailed: 令牌创建失败 InvalidToken: 意图令牌是无效的 + OtherUser: 意图是为另一个用户准备的 AggregateTypes: action: 动作 diff --git a/proto/zitadel/session/v2alpha/session.proto b/proto/zitadel/session/v2alpha/session.proto index 79f203086..a73eb8a79 100644 --- a/proto/zitadel/session/v2alpha/session.proto +++ b/proto/zitadel/session/v2alpha/session.proto @@ -45,6 +45,7 @@ message Factors { UserFactor user = 1; PasswordFactor password = 2; PasskeyFactor passkey = 3; + IntentFactor intent = 4; } message UserFactor { @@ -78,6 +79,14 @@ message PasswordFactor { ]; } +message IntentFactor { + google.protobuf.Timestamp verified_at = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"time when an intent was last checked\""; + } + ]; +} + message PasskeyFactor { google.protobuf.Timestamp verified_at = 1 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { diff --git a/proto/zitadel/session/v2alpha/session_service.proto b/proto/zitadel/session/v2alpha/session_service.proto index 0ee4b05d3..6ae18fd88 100644 --- a/proto/zitadel/session/v2alpha/session_service.proto +++ b/proto/zitadel/session/v2alpha/session_service.proto @@ -341,6 +341,11 @@ message Checks { 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.\""; } ]; + optional CheckIntent intent = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "\"Checks the intent. Requires that the userlink is already checked and a successful intent.\""; + } + ]; } message CheckUser { @@ -386,3 +391,24 @@ message CheckPasskey { } ]; } + +message CheckIntent { + string intent_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "ID of the intent, previously returned on the success response of the IDP callback" + min_length: 1; + max_length: 200; + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + string token = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "token of the intent, previously returned on the success response of the IDP callback" + min_length: 1; + max_length: 200; + example: "\"SJKL3ioIDpo342ioqw98fjp3sdf32wahb=\""; + } + ]; +}