From 9ec9ad4314198f697a7b3bfda18339f963e623ff Mon Sep 17 00:00:00 2001 From: Livio Spring Date: Tue, 3 Sep 2024 15:19:00 +0200 Subject: [PATCH] feat(oidc): sid claim for id_tokens issued through login V1 (#8525) # Which Problems Are Solved id_tokens issued for auth requests created through the login UI currently do not provide a sid claim. This is due to the fact that (SSO) sessions for the login UI do not have one and are only computed by the userAgent(ID), the user(ID) and the authentication checks of the latter. This prevents client to track sessions and terminate specific session on the end_session_endpoint. # How the Problems Are Solved - An `id` column is added to the `auth.user_sessions` table. - The `id` (prefixed with `V1_`) is set whenever a session is added or updated to active (from terminated) - The id is passed to the `oidc session` (as v2 sessionIDs), to expose it as `sid` claim # Additional Changes - refactored `getUpdateCols` to handle different column value types and add arguments for query # Additional Context - closes #8499 - relates to #8501 --- cmd/setup/32.go | 27 ++++++ cmd/setup/32.sql | 3 + cmd/setup/config.go | 1 + cmd/setup/setup.go | 2 + internal/api/oidc/auth_request.go | 1 + internal/api/oidc/token_client_credentials.go | 1 + internal/api/oidc/token_code.go | 1 + internal/api/oidc/token_exchange.go | 2 + internal/api/oidc/token_jwt_profile.go | 1 + internal/api/oidc/token_refresh.go | 1 + internal/api/ui/login/device_auth.go | 2 +- .../eventsourcing/eventstore/auth_request.go | 1 + .../eventsourcing/handler/handler.go | 2 + .../eventsourcing/handler/user_session.go | 71 ++++++++++------ internal/command/device_auth.go | 5 +- internal/command/device_auth_model.go | 2 + internal/command/device_auth_test.go | 18 +++- internal/command/oidc_session.go | 3 +- internal/command/oidc_session_test.go | 85 +++++++++++++++++++ internal/domain/auth_request.go | 2 + internal/eventstore/handler/v2/statement.go | 81 +++++++++++++++--- .../eventstore/handler/v2/statement_test.go | 49 +++++++++++ internal/repository/deviceauth/device_auth.go | 17 ++-- internal/user/model/user_session_view.go | 1 + .../repository/view/model/user_session.go | 3 + .../repository/view/user_session_by_id.sql | 3 +- .../user/repository/view/user_session_view.go | 2 + .../view/user_sessions_by_user_agent.sql | 3 +- 28 files changed, 336 insertions(+), 54 deletions(-) create mode 100644 cmd/setup/32.go create mode 100644 cmd/setup/32.sql diff --git a/cmd/setup/32.go b/cmd/setup/32.go new file mode 100644 index 000000000..224a5ee8c --- /dev/null +++ b/cmd/setup/32.go @@ -0,0 +1,27 @@ +package setup + +import ( + "context" + _ "embed" + + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/eventstore" +) + +var ( + //go:embed 32.sql + addAuthSessionID string +) + +type AddAuthSessionID struct { + dbClient *database.DB +} + +func (mig *AddAuthSessionID) Execute(ctx context.Context, _ eventstore.Event) error { + _, err := mig.dbClient.ExecContext(ctx, addAuthSessionID) + return err +} + +func (mig *AddAuthSessionID) String() string { + return "32_add_auth_sessionID" +} diff --git a/cmd/setup/32.sql b/cmd/setup/32.sql new file mode 100644 index 000000000..9c1e8116d --- /dev/null +++ b/cmd/setup/32.sql @@ -0,0 +1,3 @@ +ALTER TABLE IF EXISTS auth.user_sessions ADD COLUMN IF NOT EXISTS id TEXT; + +CREATE INDEX IF NOT EXISTS user_session_id ON auth.user_sessions (id, instance_id); \ No newline at end of file diff --git a/cmd/setup/config.go b/cmd/setup/config.go index 6ac1767ca..02f1f2aa2 100644 --- a/cmd/setup/config.go +++ b/cmd/setup/config.go @@ -116,6 +116,7 @@ type Steps struct { s29FillFieldsForProjectGrant *FillFieldsForProjectGrant s30FillFieldsForOrgDomainVerified *FillFieldsForOrgDomainVerified s31AddAggregateIndexToFields *AddAggregateIndexToFields + s32AddAuthSessionID *AddAuthSessionID } func MustNewSteps(v *viper.Viper) *Steps { diff --git a/cmd/setup/setup.go b/cmd/setup/setup.go index f9a40697a..3cfc28fad 100644 --- a/cmd/setup/setup.go +++ b/cmd/setup/setup.go @@ -160,6 +160,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s29FillFieldsForProjectGrant = &FillFieldsForProjectGrant{eventstore: eventstoreClient} steps.s30FillFieldsForOrgDomainVerified = &FillFieldsForOrgDomainVerified{eventstore: eventstoreClient} steps.s31AddAggregateIndexToFields = &AddAggregateIndexToFields{dbClient: esPusherDBClient} + steps.s32AddAuthSessionID = &AddAuthSessionID{dbClient: esPusherDBClient} err = projection.Create(ctx, projectionDBClient, eventstoreClient, config.Projections, nil, nil, nil) logging.OnError(err).Fatal("unable to start projections") @@ -216,6 +217,7 @@ func Setup(ctx context.Context, config *Config, steps *Steps, masterKey string) steps.s21AddBlockFieldToLimits, steps.s25User11AddLowerFieldsToVerifiedEmail, steps.s27IDPTemplate6SAMLNameIDFormat, + steps.s32AddAuthSessionID, } { mustExecuteMigration(ctx, eventstoreClient, step, "migration failed") } diff --git a/internal/api/oidc/auth_request.go b/internal/api/oidc/auth_request.go index dd765a304..474e12810 100644 --- a/internal/api/oidc/auth_request.go +++ b/internal/api/oidc/auth_request.go @@ -564,6 +564,7 @@ func (s *Server) authResponseToken(authReq *AuthRequest, authorizer op.Authorize domain.TokenReasonAuthRequest, nil, slices.Contains(scope, oidc.ScopeOfflineAccess), + authReq.SessionID, ) if err != nil { op.AuthRequestError(w, r, authReq, err, authorizer) diff --git a/internal/api/oidc/token_client_credentials.go b/internal/api/oidc/token_client_credentials.go index 5d19e398a..2ad429d2b 100644 --- a/internal/api/oidc/token_client_credentials.go +++ b/internal/api/oidc/token_client_credentials.go @@ -45,6 +45,7 @@ func (s *Server) ClientCredentialsExchange(ctx context.Context, r *op.ClientRequ domain.TokenReasonClientCredentials, nil, false, + "", ) if err != nil { return nil, err diff --git a/internal/api/oidc/token_code.go b/internal/api/oidc/token_code.go index b4705e9f2..e6899bed0 100644 --- a/internal/api/oidc/token_code.go +++ b/internal/api/oidc/token_code.go @@ -85,6 +85,7 @@ func (s *Server) codeExchangeV1(ctx context.Context, client *Client, req *oidc.A domain.TokenReasonAuthRequest, nil, slices.Contains(scope, oidc.ScopeOfflineAccess), + authReq.SessionID, ) if err != nil { return nil, err diff --git a/internal/api/oidc/token_exchange.go b/internal/api/oidc/token_exchange.go index c94621a4d..ec4372969 100644 --- a/internal/api/oidc/token_exchange.go +++ b/internal/api/oidc/token_exchange.go @@ -298,6 +298,7 @@ func (s *Server) createExchangeAccessToken( reason, actor, slices.Contains(scope, oidc.ScopeOfflineAccess), + "", ) if err != nil { return "", "", "", 0, err @@ -342,6 +343,7 @@ func (s *Server) createExchangeJWT( reason, actor, slices.Contains(scope, oidc.ScopeOfflineAccess), + "", ) accessToken, err = s.createJWT(ctx, client, session, getUserInfo, roleAssertion, getSigner) if err != nil { diff --git a/internal/api/oidc/token_jwt_profile.go b/internal/api/oidc/token_jwt_profile.go index 399fa5302..92d70d33d 100644 --- a/internal/api/oidc/token_jwt_profile.go +++ b/internal/api/oidc/token_jwt_profile.go @@ -53,6 +53,7 @@ func (s *Server) JWTProfile(ctx context.Context, r *op.Request[oidc.JWTProfileGr domain.TokenReasonJWTProfile, nil, false, + "", ) if err != nil { return nil, err diff --git a/internal/api/oidc/token_refresh.go b/internal/api/oidc/token_refresh.go index 1dcce2879..62f3c6dd3 100644 --- a/internal/api/oidc/token_refresh.go +++ b/internal/api/oidc/token_refresh.go @@ -67,6 +67,7 @@ func (s *Server) refreshTokenV1(ctx context.Context, client *Client, r *op.Clien domain.TokenReasonRefresh, refreshToken.Actor, true, + "", ) if err != nil { return nil, err diff --git a/internal/api/ui/login/device_auth.go b/internal/api/ui/login/device_auth.go index ca26fb956..e7ddaccd0 100644 --- a/internal/api/ui/login/device_auth.go +++ b/internal/api/ui/login/device_auth.go @@ -162,7 +162,7 @@ func (l *Login) handleDeviceAuthAction(w http.ResponseWriter, r *http.Request) { action := mux.Vars(r)["action"] switch action { case deviceAuthAllowed: - _, err = l.command.ApproveDeviceAuth(r.Context(), authDev.DeviceCode, authReq.UserID, authReq.UserOrgID, authReq.UserAuthMethodTypes(), authReq.AuthTime, authReq.PreferredLanguage, authReq.ToUserAgent()) + _, err = l.command.ApproveDeviceAuth(r.Context(), authDev.DeviceCode, authReq.UserID, authReq.UserOrgID, authReq.UserAuthMethodTypes(), authReq.AuthTime, authReq.PreferredLanguage, authReq.ToUserAgent(), authReq.SessionID) case deviceAuthDenied: _, err = l.command.CancelDeviceAuth(r.Context(), authDev.DeviceCode, domain.DeviceAuthCanceledDenied) default: diff --git a/internal/auth/repository/eventsourcing/eventstore/auth_request.go b/internal/auth/repository/eventsourcing/eventstore/auth_request.go index c49ade2a0..8791bb4f6 100644 --- a/internal/auth/repository/eventsourcing/eventstore/auth_request.go +++ b/internal/auth/repository/eventsourcing/eventstore/auth_request.go @@ -1048,6 +1048,7 @@ func (repo *AuthRequestRepo) nextSteps(ctx context.Context, request *domain.Auth if err != nil { return nil, err } + request.SessionID = userSession.ID request.DisplayName = userSession.DisplayName request.AvatarKey = userSession.AvatarKey if user.HumanView != nil && user.HumanView.PreferredLanguage != "" { diff --git a/internal/auth/repository/eventsourcing/handler/handler.go b/internal/auth/repository/eventsourcing/handler/handler.go index dedb1bce4..557890265 100644 --- a/internal/auth/repository/eventsourcing/handler/handler.go +++ b/internal/auth/repository/eventsourcing/handler/handler.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" handler2 "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/id" query2 "github.com/zitadel/zitadel/internal/query" ) @@ -40,6 +41,7 @@ func Register(ctx context.Context, configs Config, view *view.View, queries *que configs.overwrite("UserSession"), view, queries, + id.SonyFlakeGenerator(), )) projections = append(projections, newToken(ctx, diff --git a/internal/auth/repository/eventsourcing/handler/user_session.go b/internal/auth/repository/eventsourcing/handler/user_session.go index 3147b336f..98890c381 100644 --- a/internal/auth/repository/eventsourcing/handler/user_session.go +++ b/internal/auth/repository/eventsourcing/handler/user_session.go @@ -2,12 +2,14 @@ package handler import ( "context" + "slices" "time" auth_view "github.com/zitadel/zitadel/internal/auth/repository/eventsourcing/view" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" + "github.com/zitadel/zitadel/internal/id" query2 "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/org" @@ -18,12 +20,15 @@ import ( const ( userSessionTable = "auth.user_sessions" + + IDPrefixV1 = "V1_" ) type UserSession struct { - queries *query2.Queries - view *auth_view.View - es handler.EventStore + queries *query2.Queries + view *auth_view.View + es handler.EventStore + idGenerator id.Generator } var _ handler.Projection = (*UserSession)(nil) @@ -33,14 +38,16 @@ func newUserSession( config handler.Config, view *auth_view.View, queries *query2.Queries, + idGenerator id.Generator, ) *handler.Handler { return handler.NewHandler( ctx, &config, &UserSession{ - queries: queries, - view: view, - es: config.Eventstore, + queries: queries, + view: view, + es: config.Eventstore, + idGenerator: idGenerator, }, ) } @@ -187,7 +194,7 @@ func (s *UserSession) Reducers() []handler.AggregateReducer { } } -func sessionColumns(event eventstore.Event, columns ...handler.Column) ([]handler.Column, error) { +func (u *UserSession) sessionColumns(event eventstore.Event, columns ...handler.Column) ([]handler.Column, error) { userAgent, err := agentIDFromSession(event) if err != nil { return nil, err @@ -203,14 +210,34 @@ func sessionColumns(event eventstore.Event, columns ...handler.Column) ([]handle }, columns...), nil } +func (u *UserSession) sessionColumnsActivate(event eventstore.Event, columns ...handler.Column) ([]handler.Column, error) { + sessionID, err := u.idGenerator.Next() + if err != nil { + return nil, err + } + sessionID = IDPrefixV1 + sessionID + columns = slices.Grow(columns, 2) + columns = append(columns, + handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), + handler.NewCol(view_model.UserSessionKeyID, + handler.OnlySetValueInCase(userSessionTable, sessionID, + handler.ConditionOr( + handler.ColumnChangedCondition(userSessionTable, view_model.UserSessionKeyState, domain.UserSessionStateTerminated, domain.UserSessionStateActive), + handler.ColumnIsNullCondition(userSessionTable, view_model.UserSessionKeyID), + ), + ), + ), + ) + return u.sessionColumns(event, columns...) +} + func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err error) { // in case anything needs to be change here check if appendEvent function needs the change as well switch event.Type() { case user.UserV1PasswordCheckSucceededType, user.HumanPasswordCheckSucceededType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordVerification, event.CreatedAt()), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -218,9 +245,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err return handler.NewUpsertStatement(event, columns[0:3], columns), nil case user.UserV1PasswordCheckFailedType, user.HumanPasswordCheckFailedType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordVerification, time.Time{}), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -228,10 +254,9 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err return handler.NewUpsertStatement(event, columns[0:3], columns), nil case user.UserV1MFAOTPCheckSucceededType, user.HumanMFAOTPCheckSucceededType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeySecondFactorVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeySecondFactorVerificationType, domain.MFATypeTOTP), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -240,9 +265,8 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err case user.UserV1MFAOTPCheckFailedType, user.HumanMFAOTPCheckFailedType, user.HumanU2FTokenCheckFailedType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeySecondFactorVerification, time.Time{}), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -250,7 +274,7 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err return handler.NewUpsertStatement(event, columns[0:3], columns), nil case user.UserV1SignedOutType, user.HumanSignedOutType: - columns, err := sessionColumns(event, + columns, err := u.sessionColumns(event, handler.NewCol(view_model.UserSessionKeyPasswordlessVerification, time.Time{}), handler.NewCol(view_model.UserSessionKeyPasswordVerification, time.Time{}), handler.NewCol(view_model.UserSessionKeySecondFactorVerification, time.Time{}), @@ -270,10 +294,9 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err if err != nil { return nil, err } - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyExternalLoginVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeySelectedIDPConfigID, data.SelectedIDPConfigID), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -285,10 +308,9 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err if err != nil { return nil, err } - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeySecondFactorVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeySecondFactorVerificationType, domain.MFATypeU2F), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -300,11 +322,10 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err if err != nil { return nil, err } - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordlessVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeyMultiFactorVerification, event.CreatedAt()), handler.NewCol(view_model.UserSessionKeyMultiFactorVerificationType, domain.MFATypeU2FUserVerification), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -316,10 +337,9 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err if err != nil { return nil, err } - columns, err := sessionColumns(event, + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordlessVerification, time.Time{}), handler.NewCol(view_model.UserSessionKeyMultiFactorVerification, time.Time{}), - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), ) if err != nil { return nil, err @@ -429,8 +449,7 @@ func (u *UserSession) Reduce(event eventstore.Event) (_ *handler.Statement, err }, ), nil case user.HumanRegisteredType: - columns, err := sessionColumns(event, - handler.NewCol(view_model.UserSessionKeyState, domain.UserSessionStateActive), + columns, err := u.sessionColumnsActivate(event, handler.NewCol(view_model.UserSessionKeyPasswordVerification, event.CreatedAt()), ) if err != nil { diff --git a/internal/command/device_auth.go b/internal/command/device_auth.go index 67e42b763..1a151437b 100644 --- a/internal/command/device_auth.go +++ b/internal/command/device_auth.go @@ -50,6 +50,7 @@ func (c *Commands) ApproveDeviceAuth( authTime time.Time, preferredLanguage *language.Tag, userAgent *domain.UserAgent, + sessionID string, ) (*domain.ObjectDetails, error) { model, err := c.getDeviceAuthWriteModelByDeviceCode(ctx, deviceCode) if err != nil { @@ -58,7 +59,7 @@ func (c *Commands) ApproveDeviceAuth( if !model.State.Exists() { return nil, zerrors.ThrowNotFound(nil, "COMMAND-Hief9", "Errors.DeviceAuth.NotFound") } - pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent(ctx, model.aggregate, userID, userOrgID, authMethods, authTime, preferredLanguage, userAgent)) + pushedEvents, err := c.eventstore.Push(ctx, deviceauth.NewApprovedEvent(ctx, model.aggregate, userID, userOrgID, authMethods, authTime, preferredLanguage, userAgent, sessionID)) if err != nil { return nil, err } @@ -151,7 +152,7 @@ func (c *Commands) CreateOIDCSessionFromDeviceAuth(ctx context.Context, deviceCo cmd.AddSession(ctx, deviceAuthModel.UserID, deviceAuthModel.UserOrgID, - "", + deviceAuthModel.SessionID, deviceAuthModel.ClientID, deviceAuthModel.Audience, deviceAuthModel.Scopes, diff --git a/internal/command/device_auth_model.go b/internal/command/device_auth_model.go index a97b8b852..21ab1b29e 100644 --- a/internal/command/device_auth_model.go +++ b/internal/command/device_auth_model.go @@ -28,6 +28,7 @@ type DeviceAuthWriteModel struct { PreferredLanguage *language.Tag UserAgent *domain.UserAgent NeedRefreshToken bool + SessionID string } func NewDeviceAuthWriteModel(deviceCode, resourceOwner string) *DeviceAuthWriteModel { @@ -60,6 +61,7 @@ func (m *DeviceAuthWriteModel) Reduce() error { m.AuthTime = e.AuthTime m.PreferredLanguage = e.PreferredLanguage m.UserAgent = e.UserAgent + m.SessionID = e.SessionID case *deviceauth.CanceledEvent: m.State = e.Reason.State() case *deviceauth.DoneEvent: diff --git a/internal/command/device_auth_test.go b/internal/command/device_auth_test.go index 2988d058c..500c7b8f5 100644 --- a/internal/command/device_auth_test.go +++ b/internal/command/device_auth_test.go @@ -137,6 +137,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { authTime time.Time preferredLanguage *language.Tag userAgent *domain.UserAgent + sessionID string } tests := []struct { name string @@ -161,6 +162,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", }, wantErr: zerrors.ThrowNotFound(nil, "COMMAND-Hief9", "Errors.DeviceAuth.NotFound"), }, @@ -188,6 +190,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", ), ), ), @@ -201,6 +204,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", }, wantErr: pushErr, }, @@ -228,6 +232,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", ), ), ), @@ -241,6 +246,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", }, wantDetails: &domain.ObjectDetails{ ResourceOwner: "instance1", @@ -252,7 +258,7 @@ func TestCommands_ApproveDeviceAuth(t *testing.T) { c := &Commands{ eventstore: tt.fields.eventstore, } - gotDetails, err := c.ApproveDeviceAuth(tt.args.ctx, tt.args.id, tt.args.userID, tt.args.userOrgID, tt.args.authMethods, tt.args.authTime, tt.args.preferredLanguage, tt.args.userAgent) + gotDetails, err := c.ApproveDeviceAuth(tt.args.ctx, tt.args.id, tt.args.userID, tt.args.userOrgID, tt.args.authMethods, tt.args.authTime, tt.args.preferredLanguage, tt.args.userAgent, tt.args.sessionID) require.ErrorIs(t, err, tt.wantErr) assertObjectDetails(t, tt.wantDetails, gotDetails) }) @@ -607,13 +613,14 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", ), ), ), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "userID", "org1", "", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "", &language.Afrikaans, &domain.UserAgent{ FingerprintID: gu.Ptr("fp1"), IP: net.ParseIP("1.2.3.4"), @@ -657,7 +664,8 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, - Reason: domain.TokenReasonAuthRequest, + Reason: domain.TokenReasonAuthRequest, + SessionID: "sessionID", }, }, { @@ -687,13 +695,14 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { Description: gu.Ptr("firefox"), Header: http.Header{"foo": []string{"bar"}}, }, + "sessionID", ), ), ), expectFilter(), // token lifetime expectPush( oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, - "userID", "org1", "", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "", &language.Afrikaans, &domain.UserAgent{ FingerprintID: gu.Ptr("fp1"), IP: net.ParseIP("1.2.3.4"), @@ -742,6 +751,7 @@ func TestCommands_CreateOIDCSessionFromDeviceAuth(t *testing.T) { }, Reason: domain.TokenReasonAuthRequest, RefreshToken: "VjJfb2lkY1Nlc3Npb25JRC1ydF9yZWZyZXNoVG9rZW5JRDp1c2VySUQ", //V2_oidcSessionID-rt_refreshTokenID:userID + SessionID: "sessionID", }, }, } diff --git a/internal/command/oidc_session.go b/internal/command/oidc_session.go index 1fe82198b..ae201feb7 100644 --- a/internal/command/oidc_session.go +++ b/internal/command/oidc_session.go @@ -136,6 +136,7 @@ func (c *Commands) CreateOIDCSession(ctx context.Context, reason domain.TokenReason, actor *domain.TokenActor, needRefreshToken bool, + sessionID string, ) (session *OIDCSession, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -151,7 +152,7 @@ func (c *Commands) CreateOIDCSession(ctx context.Context, cmd.UserImpersonated(ctx, userID, resourceOwner, clientID, actor) } - cmd.AddSession(ctx, userID, resourceOwner, "", clientID, audience, scope, authMethods, authTime, nonce, preferredLanguage, userAgent) + cmd.AddSession(ctx, userID, resourceOwner, sessionID, clientID, audience, scope, authMethods, authTime, nonce, preferredLanguage, userAgent) if err = cmd.AddAccessToken(ctx, scope, userID, resourceOwner, reason, actor); err != nil { return nil, err } diff --git a/internal/command/oidc_session_test.go b/internal/command/oidc_session_test.go index 483528b95..a3af86d25 100644 --- a/internal/command/oidc_session_test.go +++ b/internal/command/oidc_session_test.go @@ -479,6 +479,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { reason domain.TokenReason actor *domain.TokenActor needRefreshToken bool + sessionID string } tests := []struct { name string @@ -684,6 +685,89 @@ func TestCommands_CreateOIDCSession(t *testing.T) { RefreshToken: "VjJfb2lkY1Nlc3Npb25JRC1ydF9yZWZyZXNoVG9rZW5JRDp1c2VySUQ", //V2_oidcSessionID-rt_refreshTokenID:userID }, }, + { + name: "with sessionID", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), // token lifetime + expectPush( + oidcsession.NewAddedEvent(context.Background(), &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "userID", "org1", "sessionID", "clientID", []string{"audience"}, []string{"openid", "offline_access"}, + []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, testNow, "nonce", &language.Afrikaans, + &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + ), + oidcsession.NewAccessTokenAddedEvent(context.Background(), + &oidcsession.NewAggregate("V2_oidcSessionID", "org1").Aggregate, + "at_accessTokenID", []string{"openid", "offline_access"}, time.Hour, domain.TokenReasonAuthRequest, + &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + ), + user.NewUserTokenV2AddedEvent(context.Background(), &user.NewAggregate("userID", "org1").Aggregate, "at_accessTokenID"), + ), + ), + idGenerator: mock.NewIDGeneratorExpectIDs(t, "oidcSessionID", "accessTokenID"), + defaultAccessTokenLifetime: time.Hour, + defaultRefreshTokenLifetime: 7 * 24 * time.Hour, + defaultRefreshTokenIdleLifetime: 24 * time.Hour, + keyAlgorithm: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + userID: "userID", + resourceOwner: "org1", + clientID: "clientID", + audience: []string{"audience"}, + scope: []string{"openid", "offline_access"}, + authMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + authTime: testNow, + nonce: "nonce", + preferredLanguage: &language.Afrikaans, + userAgent: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + reason: domain.TokenReasonAuthRequest, + actor: &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + needRefreshToken: false, + sessionID: "sessionID", + }, + want: &OIDCSession{ + TokenID: "V2_oidcSessionID-at_accessTokenID", + ClientID: "clientID", + UserID: "userID", + Audience: []string{"audience"}, + Expiration: time.Time{}.Add(time.Hour), + Scope: []string{"openid", "offline_access"}, + AuthMethods: []domain.UserAuthMethodType{domain.UserAuthMethodTypePassword}, + AuthTime: testNow, + Nonce: "nonce", + PreferredLanguage: &language.Afrikaans, + UserAgent: &domain.UserAgent{ + FingerprintID: gu.Ptr("fp1"), + IP: net.ParseIP("1.2.3.4"), + Description: gu.Ptr("firefox"), + Header: http.Header{"foo": []string{"bar"}}, + }, + Reason: domain.TokenReasonAuthRequest, + Actor: &domain.TokenActor{ + UserID: "user2", + Issuer: "foo.com", + }, + SessionID: "sessionID", + }, + }, { name: "impersonation not allowed", fields: fields{ @@ -839,6 +923,7 @@ func TestCommands_CreateOIDCSession(t *testing.T) { tt.args.reason, tt.args.actor, tt.args.needRefreshToken, + tt.args.sessionID, ) require.ErrorIs(t, err, tt.wantErr) if got != nil { diff --git a/internal/domain/auth_request.go b/internal/domain/auth_request.go index e302d67cb..01b6ae25d 100644 --- a/internal/domain/auth_request.go +++ b/internal/domain/auth_request.go @@ -62,6 +62,8 @@ type AuthRequest struct { SAMLRequestID string // orgID the policies were last loaded with policyOrgID string + // SessionID is set to the computed sessionID of the login session table + SessionID string } func (a *AuthRequest) SetPolicyOrgID(id string) { diff --git a/internal/eventstore/handler/v2/statement.go b/internal/eventstore/handler/v2/statement.go index 2eb919308..43d181848 100644 --- a/internal/eventstore/handler/v2/statement.go +++ b/internal/eventstore/handler/v2/statement.go @@ -146,18 +146,17 @@ func NewUpsertStatement(event eventstore.Event, conflictCols []Column, values [] conflictTarget[i] = col.Name } - config := execConfig{ - args: args, - } + config := execConfig{} if len(values) == 0 { config.err = ErrNoValues } - updateCols, updateVals := getUpdateCols(values, conflictTarget) + updateCols, updateVals, args := getUpdateCols(values, conflictTarget, params, args) if len(updateCols) == 0 || len(updateVals) == 0 { config.err = ErrNoValues } + config.args = args q := func(config execConfig) string { var updateStmt string @@ -194,18 +193,78 @@ func OnlySetValueOnInsert(table string, value interface{}) *onlySetValueOnInsert } } -func getUpdateCols(cols []Column, conflictTarget []string) (updateCols, updateVals []string) { +type onlySetValueInCase struct { + Table string + Value interface{} + Condition Condition +} + +func (c *onlySetValueInCase) GetValue() interface{} { + return c.Value +} + +// ColumnChangedCondition checks the current value and if it changed to a specific new value +func ColumnChangedCondition(table, column string, currentValue, newValue interface{}) Condition { + return func(param string) (string, []any) { + index, _ := strconv.Atoi(param) + return fmt.Sprintf("%[1]s.%[2]s = $%[3]d AND EXCLUDED.%[2]s = $%[4]d", table, column, index, index+1), []any{currentValue, newValue} + } +} + +// ColumnIsNullCondition checks if the current value is null +func ColumnIsNullCondition(table, column string) Condition { + return func(param string) (string, []any) { + return fmt.Sprintf("%[1]s.%[2]s IS NULL", table, column), nil + } +} + +// ConditionOr links multiple Conditions by OR +func ConditionOr(conditions ...Condition) Condition { + return func(param string) (_ string, args []any) { + if len(conditions) == 0 { + return "", nil + } + b := strings.Builder{} + s, arg := conditions[0](param) + b.WriteString(s) + args = append(args, arg...) + for i := 1; i < len(conditions); i++ { + b.WriteString(" OR ") + s, condArgs := conditions[i](param) + b.WriteString(s) + args = append(args, condArgs...) + } + return b.String(), args + } +} + +// OnlySetValueInCase will only update to the desired value if the condition applies +func OnlySetValueInCase(table string, value interface{}, condition Condition) *onlySetValueInCase { + return &onlySetValueInCase{ + Table: table, + Value: value, + Condition: condition, + } +} + +func getUpdateCols(cols []Column, conflictTarget, params []string, args []interface{}) (updateCols, updateVals []string, updatedArgs []interface{}) { updateCols = make([]string, len(cols)) updateVals = make([]string, len(cols)) + updatedArgs = args for i := len(cols) - 1; i >= 0; i-- { col := cols[i] - table := "EXCLUDED" - if onlyOnInsert, ok := col.Value.(*onlySetValueOnInsert); ok { - table = onlyOnInsert.Table - } updateCols[i] = col.Name - updateVals[i] = table + "." + col.Name + switch v := col.Value.(type) { + case *onlySetValueOnInsert: + updateVals[i] = v.Table + "." + col.Name + case *onlySetValueInCase: + s, condArgs := v.Condition(strconv.Itoa(len(params) + 1)) + updatedArgs = append(updatedArgs, condArgs...) + updateVals[i] = fmt.Sprintf("CASE WHEN %[1]s THEN EXCLUDED.%[2]s ELSE %[3]s.%[2]s END", s, col.Name, v.Table) + default: + updateVals[i] = "EXCLUDED" + "." + col.Name + } for _, conflict := range conflictTarget { if conflict == col.Name { copy(updateCols[i:], updateCols[i+1:]) @@ -221,7 +280,7 @@ func getUpdateCols(cols []Column, conflictTarget []string) (updateCols, updateVa } } - return updateCols, updateVals + return updateCols, updateVals, updatedArgs } func NewUpdateStatement(event eventstore.Event, values []Column, conditions []Condition, opts ...execOption) *Statement { diff --git a/internal/eventstore/handler/v2/statement_test.go b/internal/eventstore/handler/v2/statement_test.go index a2bae8b53..787ec105e 100644 --- a/internal/eventstore/handler/v2/statement_test.go +++ b/internal/eventstore/handler/v2/statement_test.go @@ -451,6 +451,55 @@ func TestNewUpsertStatement(t *testing.T) { }, }, }, + { + name: "correct all *onlySetValueInCase", + args: args{ + table: "my_table", + event: &testEvent{ + aggregateType: "agg", + sequence: 1, + previousSequence: 0, + }, + conflictCols: []Column{ + NewCol("col1", nil), + }, + values: []Column{ + { + Name: "col1", + Value: "val1", + }, + { + Name: "col2", + Value: &onlySetValueInCase{ + Table: "some.table", + Value: "val2", + Condition: ConditionOr( + ColumnChangedCondition("some.table", "val3", 0, 1), + ColumnIsNullCondition("some.table", "val3"), + ), + }, + }, + }, + }, + want: want{ + table: "my_table", + aggregateType: "agg", + sequence: 1, + previousSequence: 1, + executer: &wantExecuter{ + params: []params{ + { + query: "INSERT INTO my_table (col1, col2) VALUES ($1, $2) ON CONFLICT (col1) DO UPDATE SET col2 = CASE WHEN some.table.val3 = $3 AND EXCLUDED.val3 = $4 OR some.table.val3 IS NULL THEN EXCLUDED.col2 ELSE some.table.col2 END", + args: []interface{}{"val1", "val2", 0, 1}, + }, + }, + shouldExecute: true, + }, + isErr: func(err error) bool { + return err == nil + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/repository/deviceauth/device_auth.go b/internal/repository/deviceauth/device_auth.go index d2f688669..7d96d8c2c 100644 --- a/internal/repository/deviceauth/device_auth.go +++ b/internal/repository/deviceauth/device_auth.go @@ -72,6 +72,7 @@ type ApprovedEvent struct { AuthTime time.Time PreferredLanguage *language.Tag UserAgent *domain.UserAgent + SessionID string } func (e *ApprovedEvent) SetBaseEvent(b *eventstore.BaseEvent) { @@ -95,17 +96,19 @@ func NewApprovedEvent( authTime time.Time, preferredLanguage *language.Tag, userAgent *domain.UserAgent, + sessionID string, ) *ApprovedEvent { return &ApprovedEvent{ - eventstore.NewBaseEventForPush( + BaseEvent: eventstore.NewBaseEventForPush( ctx, aggregate, ApprovedEventType, ), - userID, - userOrgID, - userAuthMethods, - authTime, - preferredLanguage, - userAgent, + UserID: userID, + UserOrgID: userOrgID, + UserAuthMethods: userAuthMethods, + AuthTime: authTime, + PreferredLanguage: preferredLanguage, + UserAgent: userAgent, + SessionID: sessionID, } } diff --git a/internal/user/model/user_session_view.go b/internal/user/model/user_session_view.go index 7a23fa2a6..4335c6db7 100644 --- a/internal/user/model/user_session_view.go +++ b/internal/user/model/user_session_view.go @@ -27,6 +27,7 @@ type UserSessionView struct { MultiFactorVerification time.Time MultiFactorVerificationType domain.MFAType Sequence uint64 + ID string } type UserSessionSearchRequest struct { diff --git a/internal/user/repository/view/model/user_session.go b/internal/user/repository/view/model/user_session.go index 48eafd50a..03ecc592b 100644 --- a/internal/user/repository/view/model/user_session.go +++ b/internal/user/repository/view/model/user_session.go @@ -32,6 +32,7 @@ const ( UserSessionKeyPasswordlessVerification = "passwordless_verification" UserSessionKeyExternalLoginVerification = "external_login_verification" UserSessionKeySelectedIDPConfigID = "selected_idp_config_id" + UserSessionKeyID = "id" ) type UserSessionView struct { @@ -59,6 +60,7 @@ type UserSessionView struct { MultiFactorVerificationType sql.NullInt32 `json:"-" gorm:"column:multi_factor_verification_type"` Sequence uint64 `json:"-" gorm:"column:sequence"` InstanceID string `json:"instanceID" gorm:"column:instance_id;primary_key"` + ID sql.NullString `json:"id" gorm:"-"` } type userAgentIDPayload struct { @@ -95,6 +97,7 @@ func UserSessionToModel(userSession *UserSessionView) *model.UserSessionView { MultiFactorVerification: userSession.MultiFactorVerification.Time, MultiFactorVerificationType: domain.MFAType(userSession.MultiFactorVerificationType.Int32), Sequence: userSession.Sequence, + ID: userSession.ID.String, } } diff --git a/internal/user/repository/view/user_session_by_id.sql b/internal/user/repository/view/user_session_by_id.sql index 884ace168..a41d48093 100644 --- a/internal/user/repository/view/user_session_by_id.sql +++ b/internal/user/repository/view/user_session_by_id.sql @@ -17,7 +17,8 @@ SELECT s.creation_date, s.multi_factor_verification, s.multi_factor_verification_type, s.sequence, - s.instance_id + s.instance_id, + s.id FROM auth.user_sessions s LEFT JOIN projections.users13 u ON s.user_id = u.id AND s.instance_id = u.instance_id LEFT JOIN projections.users13_humans h ON s.user_id = h.user_id AND s.instance_id = h.instance_id diff --git a/internal/user/repository/view/user_session_view.go b/internal/user/repository/view/user_session_view.go index 2de623379..35c1bfc74 100644 --- a/internal/user/repository/view/user_session_view.go +++ b/internal/user/repository/view/user_session_view.go @@ -65,6 +65,7 @@ func scanUserSession(row *sql.Row) (*model.UserSessionView, error) { &session.MultiFactorVerificationType, &session.Sequence, &session.InstanceID, + &session.ID, ) if errors.Is(err, sql.ErrNoRows) { return nil, zerrors.ThrowNotFound(nil, "VIEW-NGBs1", "Errors.UserSession.NotFound") @@ -97,6 +98,7 @@ func scanUserSessions(rows *sql.Rows) ([]*model.UserSessionView, error) { &session.MultiFactorVerificationType, &session.Sequence, &session.InstanceID, + &session.ID, ) if err != nil { return nil, err diff --git a/internal/user/repository/view/user_sessions_by_user_agent.sql b/internal/user/repository/view/user_sessions_by_user_agent.sql index 476f43ba8..cb2171c09 100644 --- a/internal/user/repository/view/user_sessions_by_user_agent.sql +++ b/internal/user/repository/view/user_sessions_by_user_agent.sql @@ -17,7 +17,8 @@ SELECT s.creation_date, s.multi_factor_verification, s.multi_factor_verification_type, s.sequence, - s.instance_id + s.instance_id, + s.id FROM auth.user_sessions s LEFT JOIN projections.users13 u ON s.user_id = u.id AND s.instance_id = u.instance_id LEFT JOIN projections.users13_humans h ON s.user_id = h.user_id AND s.instance_id = h.instance_id