diff --git a/backend/.mockery.private.yml b/backend/.mockery.private.yml index d73de667e0..c8b823685f 100644 --- a/backend/.mockery.private.yml +++ b/backend/.mockery.private.yml @@ -132,7 +132,15 @@ packages: structname: '{{.InterfaceName}}Mock' pkgname: flowexec filename: "{{.InterfaceName}}_mock_test.go" - + + github.com/thunder-id/thunderid/internal/flow/session: + config: + all: true + dir: internal/flow/session + structname: '{{.InterfaceName}}Mock' + pkgname: session + filename: "{{.InterfaceName}}_mock_test.go" + github.com/thunder-id/thunderid/internal/flow/mgt: config: all: true diff --git a/backend/.mockery.public.yml b/backend/.mockery.public.yml index f1dcfa7b3b..3631b16d17 100644 --- a/backend/.mockery.public.yml +++ b/backend/.mockery.public.yml @@ -383,7 +383,18 @@ packages: structname: '{{.InterfaceName}}Mock' pkgname: flowexecmock filename: "{{.InterfaceName}}_mock.go" - + + github.com/thunder-id/thunderid/internal/flow/session: + config: + dir: tests/mocks/flow/sessionmock + structname: '{{.InterfaceName}}Mock' + pkgname: sessionmock + filename: "{{.InterfaceName}}_mock.go" + interfaces: + Service: + Resolver: + HandleTransport: + github.com/thunder-id/thunderid/internal/flow/mgt: config: all: true diff --git a/backend/cmd/server/servicemanager.go b/backend/cmd/server/servicemanager.go index 0ee4675e09..3cb360e163 100644 --- a/backend/cmd/server/servicemanager.go +++ b/backend/cmd/server/servicemanager.go @@ -62,6 +62,7 @@ import ( "github.com/thunder-id/thunderid/internal/flow/graphbuilder" "github.com/thunder-id/thunderid/internal/flow/interceptor" flowmgt "github.com/thunder-id/thunderid/internal/flow/mgt" + flowsession "github.com/thunder-id/thunderid/internal/flow/session" "github.com/thunder-id/thunderid/internal/group" "github.com/thunder-id/thunderid/internal/idp" "github.com/thunder-id/thunderid/internal/inboundclient" @@ -317,7 +318,25 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa attributeCacheService := attributecache.Initialize(runtimeStoreProvider) emailClient := initEmailClient(ctx, logger) + + // Initialize server-wide configuration after its handler dependencies. + serverConfigHandlers := map[serverconfig.ConfigName]serverconfig.ServerConfigHandlerInterface{ + serverconfig.ConfigNameCORS: cors.OriginHandler{}, + serverconfig.ConfigNameDefaultResourceServer: resource.NewDefaultResourceServerConfigHandler(resourceService), + serverconfig.ConfigNameSession: flowsession.ConfigHandler{}, + } + serverConfigService, serverConfigExporter, err := serverconfig.Initialize(mux, cacheManager, serverConfigHandlers) + if err != nil { + logger.Fatal(ctx, "Failed to initialize server config service", log.Error(err)) + } + exporters = append(exporters, serverConfigExporter) + + // CORS origins come from the server-config cors section. + cors.InitializeDynamicMatcher(serverConfigService) + flowConfig := flowconfig.FromServerRuntime() + sessionService, sessionCfg := initSessionService(ctx, serverConfigService, runtime.Config.Server.Identifier, logger) + flowConfig.Session = sessionCfg flowFactory, execRegistry, interceptorRegistry, graphBuilder := initializeFlowCoreAndExecutor(ctx, logger, cacheManager, executor.ExecutorDependencies{ OUService: ouService, @@ -344,6 +363,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa GithubSvc: githubAuthnService, GoogleSvc: googleAuthnService, OpenID4VPVerifierSvc: openid4vpSvc, + SessionService: sessionService, }, interceptor.InterceptorDependencies{}, flowConfig, @@ -422,20 +442,6 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa // Initialize flow metadata service _ = flowmeta.Initialize(mux, actorProvider, ouService, designResolveService, i18nService) - // Initialize server-wide configuration after its handler dependencies. - serverConfigHandlers := map[serverconfig.ConfigName]serverconfig.ServerConfigHandlerInterface{ - serverconfig.ConfigNameCORS: cors.OriginHandler{}, - serverconfig.ConfigNameDefaultResourceServer: resource.NewDefaultResourceServerConfigHandler(resourceService), - } - serverConfigService, serverConfigExporter, err := serverconfig.Initialize(mux, cacheManager, serverConfigHandlers) - if err != nil { - logger.Fatal(ctx, "Failed to initialize server config service", log.Error(err)) - } - exporters = append(exporters, serverConfigExporter) - - // CORS origins come from the server-config cors section. - cors.InitializeDynamicMatcher(serverConfigService) - // Initialize export service with collected exporters _ = export.Initialize(mux, exporters) @@ -461,10 +467,9 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa serverConfigService, ) - flowCfg := flowconfig.FromServerRuntime() flowExecService, err := flowexec.Initialize(mux, flowMgtService, actorProvider, execRegistry, interceptorRegistry, observabilitySvc, runtimeCryptoSvc, graphBuilder, - runtimeStoreProvider, transactioner, flowCfg) + runtimeStoreProvider, transactioner, flowConfig) if err != nil { logger.Fatal(ctx, "Failed to initialize flow execution service", log.Error(err)) } @@ -528,6 +533,34 @@ func unregisterServices() { observabilitySvc.Shutdown() } +// initSessionService reads the effective SSO session configuration from the server-config section and +// builds the session service, returning both so the caller can thread the config into flowexec too. +func initSessionService(ctx context.Context, svc serverconfig.ServerConfigService, deploymentID string, + logger *log.Logger) (flowsession.Service, flowsession.Config) { + cfg := readSessionConfig(ctx, svc, logger) + sessionService, err := flowsession.Initialize(dbprovider.GetDBProvider(), deploymentID, + flowsession.NewTimeouts(cfg.IdleTimeoutSeconds, cfg.AbsoluteTimeoutSeconds)) + if err != nil { + logger.Fatal(ctx, "Failed to initialize SSO session service", log.Error(err)) + } + return sessionService, cfg +} + +// readSessionConfig reads the effective SSO session lifetime configuration from the server-config +// "session" section. An unset section resolves to the zero Config, which NewTimeouts turns into the +// built-in defaults; a read error is non-fatal for the same reason, so it logs and falls back. +func readSessionConfig(ctx context.Context, svc serverconfig.ServerConfigService, + logger *log.Logger) flowsession.Config { + merged, svcErr := svc.GetMergedConfig(ctx, string(serverconfig.ConfigNameSession)) + if svcErr != nil { + logger.Warn(ctx, "Failed to read session server config; using default timeouts", + log.String("code", svcErr.Code)) + return flowsession.Config{} + } + cfg, _ := merged.(flowsession.Config) + return cfg +} + // initEmailClient initializes the email client, returning nil if not configured. func initEmailClient(ctx context.Context, logger *log.Logger) email.EmailClientInterface { client, err := email.Initialize() diff --git a/backend/dbscripts/operationdb/postgres.sql b/backend/dbscripts/operationdb/postgres.sql index 428f38bc76..556650dfeb 100644 --- a/backend/dbscripts/operationdb/postgres.sql +++ b/backend/dbscripts/operationdb/postgres.sql @@ -32,3 +32,59 @@ CREATE UNIQUE INDEX idx_revoked_token_jti_deployment ON "REVOKED_TOKEN" (DEPLOYM -- Index for expiry time on REVOKED_TOKEN (supports cleanup and expiry checks). CREATE INDEX idx_revoked_token_expiry_time ON "REVOKED_TOKEN" (EXPIRY_TIME); + +-- Table to store SSO sessions, grouped by flow (FLOW_ID) and resolved by an opaque handle. +-- Part of the database.operation classification: persistent session state that must survive a +-- runtime database flush. +CREATE TABLE "SSO_SESSION" ( + SESSION_ID VARCHAR(36) NOT NULL, + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + SUBJECT_ID VARCHAR(36) NOT NULL, + FLOW_ID VARCHAR(36) NOT NULL, + FLOW_VERSION INTEGER NOT NULL, + FLOW_EXECUTION_ID VARCHAR(255) NOT NULL, + HANDLE_ID VARCHAR(255) NOT NULL, + AUTHENTICATED_AT TIMESTAMP NOT NULL, + CREATED_AT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_ACTIVE_AT TIMESTAMP NOT NULL, + IDLE_EXPIRES_AT TIMESTAMP, + ABSOLUTE_EXPIRES_AT TIMESTAMP, + STATE VARCHAR(50) NOT NULL, + VERSION INTEGER NOT NULL, + UPDATED_AT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID) +); + +-- Unique index for handle lookup on SSO_SESSION (one session per handle, per deployment) +CREATE UNIQUE INDEX idx_sso_session_handle_id ON "SSO_SESSION" (HANDLE_ID, DEPLOYMENT_ID); + +-- Unique index enforcing one session per establishing flow execution (per deployment). Lets +-- concurrent joins in a single flow execution converge on one session instead of duplicating it. +CREATE UNIQUE INDEX idx_sso_session_flow_execution ON "SSO_SESSION" (FLOW_EXECUTION_ID, DEPLOYMENT_ID); + +-- Index for subject + flow lookup on SSO_SESSION +CREATE INDEX idx_sso_session_subject_flow ON "SSO_SESSION" (SUBJECT_ID, FLOW_ID, DEPLOYMENT_ID); + +-- Index for absolute expiry on SSO_SESSION (supports cleanup) +CREATE INDEX idx_sso_session_absolute_expires_at ON "SSO_SESSION" (ABSOLUTE_EXPIRES_AT); + +-- Table to store the durable session context for an SSO session, one row per checkpoint. +CREATE TABLE "SSO_SESSION_CONTEXT" ( + SESSION_ID VARCHAR(36) NOT NULL, + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + CHECKPOINT_ID VARCHAR(255) NOT NULL, + CONTEXT TEXT, + CONTEXT_VERSION INTEGER NOT NULL, + CREATED_AT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID, CHECKPOINT_ID) +); + +-- Table to record the applications participating in an SSO session (1:many by SESSION_ID). +CREATE TABLE "SSO_SESSION_PARTICIPANT" ( + SESSION_ID VARCHAR(36) NOT NULL, + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + APP_ID VARCHAR(36) NOT NULL, + FIRST_JOINED_AT TIMESTAMP NOT NULL, + LAST_ACTIVE_AT TIMESTAMP NOT NULL, + PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID, APP_ID) +); diff --git a/backend/dbscripts/operationdb/sqlite.sql b/backend/dbscripts/operationdb/sqlite.sql index 2e6d1e8669..dfed58630a 100644 --- a/backend/dbscripts/operationdb/sqlite.sql +++ b/backend/dbscripts/operationdb/sqlite.sql @@ -32,3 +32,59 @@ CREATE UNIQUE INDEX idx_revoked_token_jti_deployment ON "REVOKED_TOKEN" (DEPLOYM -- Index for expiry time on REVOKED_TOKEN (supports cleanup and expiry checks). CREATE INDEX idx_revoked_token_expiry_time ON "REVOKED_TOKEN" (EXPIRY_TIME); + +-- Table to store SSO sessions, grouped by flow (FLOW_ID) and resolved by an opaque handle. +-- Part of the database.operation classification: persistent session state that must survive a +-- runtime database flush. +CREATE TABLE "SSO_SESSION" ( + SESSION_ID VARCHAR(36) NOT NULL, + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + SUBJECT_ID VARCHAR(36) NOT NULL, + FLOW_ID VARCHAR(36) NOT NULL, + FLOW_VERSION INTEGER NOT NULL, + FLOW_EXECUTION_ID VARCHAR(255) NOT NULL, + HANDLE_ID VARCHAR(255) NOT NULL, + AUTHENTICATED_AT DATETIME NOT NULL, + CREATED_AT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + LAST_ACTIVE_AT DATETIME NOT NULL, + IDLE_EXPIRES_AT DATETIME, + ABSOLUTE_EXPIRES_AT DATETIME, + STATE VARCHAR(50) NOT NULL, + VERSION INTEGER NOT NULL, + UPDATED_AT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID) +); + +-- Unique index for handle lookup on SSO_SESSION (one session per handle, per deployment) +CREATE UNIQUE INDEX idx_sso_session_handle_id ON "SSO_SESSION" (HANDLE_ID, DEPLOYMENT_ID); + +-- Unique index enforcing one session per establishing flow execution (per deployment). Lets +-- concurrent joins in a single flow execution converge on one session instead of duplicating it. +CREATE UNIQUE INDEX idx_sso_session_flow_execution ON "SSO_SESSION" (FLOW_EXECUTION_ID, DEPLOYMENT_ID); + +-- Index for subject + flow lookup on SSO_SESSION +CREATE INDEX idx_sso_session_subject_flow ON "SSO_SESSION" (SUBJECT_ID, FLOW_ID, DEPLOYMENT_ID); + +-- Index for absolute expiry on SSO_SESSION (supports cleanup) +CREATE INDEX idx_sso_session_absolute_expires_at ON "SSO_SESSION" (ABSOLUTE_EXPIRES_AT); + +-- Table to store the durable session context for an SSO session, one row per checkpoint. +CREATE TABLE "SSO_SESSION_CONTEXT" ( + SESSION_ID VARCHAR(36) NOT NULL, + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + CHECKPOINT_ID VARCHAR(255) NOT NULL, + CONTEXT TEXT, + CONTEXT_VERSION INTEGER NOT NULL, + CREATED_AT TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID, CHECKPOINT_ID) +); + +-- Table to record the applications participating in an SSO session (1:many by SESSION_ID). +CREATE TABLE "SSO_SESSION_PARTICIPANT" ( + SESSION_ID VARCHAR(36) NOT NULL, + DEPLOYMENT_ID VARCHAR(255) NOT NULL, + APP_ID VARCHAR(36) NOT NULL, + FIRST_JOINED_AT DATETIME NOT NULL, + LAST_ACTIVE_AT DATETIME NOT NULL, + PRIMARY KEY (SESSION_ID, DEPLOYMENT_ID, APP_ID) +); diff --git a/backend/internal/flow/common/constants.go b/backend/internal/flow/common/constants.go index 8138fbd29e..62318f9356 100644 --- a/backend/internal/flow/common/constants.go +++ b/backend/internal/flow/common/constants.go @@ -120,8 +120,13 @@ const ( NodePropertyAuthMethodMapping = "authMethodMapping" // NodePropertySkipInterceptors indicates whether to skip interceptor execution for the current node. NodePropertySkipInterceptors = "skipInterceptors" + // NodePropertyCheckpointRef is set on an SSO-Check node to name the Session (join) node id whose + // checkpoint it guards. The checkpoint id is that join node's id, so the skip and join of one + // checkpoint pair by it. Absent/empty means the node is not part of a checkpoint pair. + NodePropertyCheckpointRef = "checkpointRef" ) +// RuntimeData keys. const ( // RuntimeKeyUserAutoProvisioned indicates whether the user was auto-provisioned RuntimeKeyUserAutoProvisioned = "userAutoProvisioned" @@ -185,6 +190,9 @@ const ( RuntimeKeyOpenID4VPState = "openid4vpVerificationState" // RuntimeKeyRequestedAuthClasses holds the space-separated ACR values from acr_values. RuntimeKeyRequestedAuthClasses = "requested_auth_classes" + // RuntimeKeyMaxAge holds the OIDC max_age request parameter (maximum allowed elapsed seconds + // since the subject last authenticated). + RuntimeKeyMaxAge = "max_age" // RuntimeKeySelectedAuthClass holds the ACR value of the chosen authentication method. RuntimeKeySelectedAuthClass = "selected_auth_class" // RuntimeKeyAllowedLoginOptions holds the space-separated action refs allowed on a LOGIN_OPTIONS node. @@ -199,8 +207,33 @@ const ( // RuntimeKeyAuthorizationRequestID holds the auth request identifier bound to the current flow // execution (the OAuth authorize authId or the CIBA auth_req_id), if applicable. RuntimeKeyAuthorizationRequestID = "authorizationRequestId" + // RuntimeKeySSOSessionPresent is the prefix of the per-checkpoint flag recording whether the + // SSO-Check node found a live session that already has this checkpoint's snapshot ("true") or not. + // It is scoped per checkpoint via SSOCheckpointKey; the paired Session node reads it to choose + // load vs save. "true" commits the join to loading (fail closed if the snapshot is gone). + RuntimeKeySSOSessionPresent = "ssoSessionPresent" + // RuntimeKeySSOSessionSaved is the prefix of the per-checkpoint guard holding the handle under + // which a checkpoint's context was saved, making the save idempotent if the join re-executes. It + // is scoped per checkpoint via SSOCheckpointKey. + RuntimeKeySSOSessionSaved = "ssoSessionSaved" + // RuntimeKeyAuthTime holds the Unix timestamp (seconds) at which the subject authenticated + // for the current session, carried across the SSO path for downstream assurance checks. + RuntimeKeyAuthTime = "ssoAuthTime" + // RuntimeKeySSOSessionHandle is the SSO session handle key. It is used both as the RuntimeData key + // that carries the run's session handle across nodes (resolved on reuse, minted on a fresh login) + // and as the ExecutorResponse EngineData key the Session node uses to hand a freshly minted handle + // to the transport layer for the per-flow cookie. Using the generic EngineData channel keeps SSO + // concepts out of the reusable engine contract. + RuntimeKeySSOSessionHandle = "ssoSessionHandle" ) +// SSOCheckpointKey scopes a per-checkpoint SSO control key (RuntimeKeySSOSessionPresent, +// RuntimeKeySSOSessionSaved) to a checkpoint id, so multiple skip/join checkpoints in one flow keep +// independent control state within the shared RuntimeData map. +func SSOCheckpointKey(base, checkpointID string) string { + return base + ":" + checkpointID +} + // MetaComponentType constants define known component types used in flow meta definitions. const ( // MetaComponentTypeBlock represents a block container component. diff --git a/backend/internal/flow/common/model.go b/backend/internal/flow/common/model.go index 62a6ddad1c..b77984b06e 100644 --- a/backend/internal/flow/common/model.go +++ b/backend/internal/flow/common/model.go @@ -67,21 +67,23 @@ type Prompt struct { // NodeResponse represents the response from a node execution type NodeResponse struct { - Status NodeStatus `json:"status"` - Type NodeResponseType `json:"type"` - Error *tidcommon.ServiceError `json:"error,omitempty"` - Inputs []providers.Input `json:"inputs,omitempty"` - AdditionalData map[string]string `json:"additionalData,omitempty"` - RedirectURL string `json:"redirectUrl,omitempty"` - Actions []Action `json:"actions,omitempty"` - Meta interface{} `json:"meta,omitempty"` - NextNodeID string `json:"nextNodeId,omitempty"` - RuntimeData map[string]string `json:"runtimeData,omitempty"` - ForwardedData map[string]interface{} `json:"forwardedData,omitempty"` - Assertion string `json:"assertion,omitempty"` - FieldErrors []FieldError `json:"fieldErrors,omitempty"` - AuthUser providers.AuthUser `json:"-"` - CallTargetFlowID string `json:"callTargetFlowId,omitempty"` + Status NodeStatus `json:"status"` + Type NodeResponseType `json:"type"` + Error *tidcommon.ServiceError `json:"error,omitempty"` + Inputs []providers.Input `json:"inputs,omitempty"` + AdditionalData map[string]string `json:"additionalData,omitempty"` + RedirectURL string `json:"redirectUrl,omitempty"` + Actions []Action `json:"actions,omitempty"` + Meta interface{} `json:"meta,omitempty"` + NextNodeID string `json:"nextNodeId,omitempty"` + RuntimeData map[string]string `json:"runtimeData,omitempty"` + ForwardedData map[string]interface{} `json:"forwardedData,omitempty"` + Assertion string `json:"assertion,omitempty"` + FieldErrors []FieldError `json:"fieldErrors,omitempty"` + AuthUser providers.AuthUser `json:"-"` + // EngineData carries executor output the engine consumes internally; never serialized to the client. + EngineData map[string]string `json:"-"` + CallTargetFlowID string `json:"callTargetFlowId,omitempty"` } // InterceptorResponse represents the response from an interceptor execution diff --git a/backend/internal/flow/config/config.go b/backend/internal/flow/config/config.go index 1e9a85a105..fe4d9461d9 100644 --- a/backend/internal/flow/config/config.go +++ b/backend/internal/flow/config/config.go @@ -20,6 +20,7 @@ package flowconfig import ( + flowsession "github.com/thunder-id/thunderid/internal/flow/session" "github.com/thunder-id/thunderid/internal/system/config" engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" ) @@ -27,12 +28,19 @@ import ( // Config holds configuration values required by flow services. type Config struct { Flow engineconfig.FlowConfig + // SecureCookies marks SSO cookies Secure; it is derived from the deployment's HTTP-only setting. + SecureCookies bool + // Session holds the SSO session lifetime configuration used for both server-side timeouts and the + // cookie lifetime. It is sourced from the server-config "session" section at the composition root, + // not the static server runtime, so FromServerRuntime leaves it zero for the caller to populate. + Session flowsession.Config } // FromServerRuntime builds flow configuration from the global server runtime. func FromServerRuntime() Config { runtime := config.GetServerRuntime() return Config{ - Flow: runtime.Config.Flow, + Flow: runtime.Config.Flow, + SecureCookies: !runtime.Config.Server.HTTPOnly, } } diff --git a/backend/internal/flow/config/config_test.go b/backend/internal/flow/config/config_test.go index 947a366549..4083ccaec3 100644 --- a/backend/internal/flow/config/config_test.go +++ b/backend/internal/flow/config/config_test.go @@ -47,6 +47,9 @@ func (s *FlowConfigTestSuite) TearDownTest() { func (s *FlowConfigTestSuite) TestFromServerRuntime() { cfg := &config.Config{ Flow: engineconfig.FlowConfig{UserOnboardingFlowHandle: "onboarding-handle"}, + Server: engineconfig.ServerConfig{ + HTTPOnly: true, + }, } err := config.InitializeServerRuntime("/tmp/test-flow-config", cfg) s.Require().NoError(err) @@ -54,4 +57,8 @@ func (s *FlowConfigTestSuite) TestFromServerRuntime() { result := FromServerRuntime() s.Equal("onboarding-handle", result.Flow.UserOnboardingFlowHandle) + s.False(result.SecureCookies, "HTTPOnly deployment must not mark cookies Secure") + // Session config is sourced from the server-config section at the composition root, not here. + s.Zero(result.Session.IdleTimeoutSeconds) + s.Zero(result.Session.AbsoluteTimeoutSeconds) } diff --git a/backend/internal/flow/core/task_execution_node.go b/backend/internal/flow/core/task_execution_node.go index 9a497443c1..c0341d6b27 100644 --- a/backend/internal/flow/core/task_execution_node.go +++ b/backend/internal/flow/core/task_execution_node.go @@ -215,6 +215,7 @@ func (n *taskExecutionNode) buildNodeResponse(execResp *providers.ExecutorRespon ForwardedData: execResp.ForwardedData, Assertion: execResp.Assertion, AuthUser: execResp.AuthUser, + EngineData: execResp.EngineData, } if nodeResp.AdditionalData == nil { nodeResp.AdditionalData = make(map[string]string) diff --git a/backend/internal/flow/executor/auth_assert_assurance_test.go b/backend/internal/flow/executor/auth_assert_assurance_test.go new file mode 100644 index 0000000000..88c804c97a --- /dev/null +++ b/backend/internal/flow/executor/auth_assert_assurance_test.go @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package executor + +import ( + "context" + "strconv" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/thunder-id/thunderid/internal/flow/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +func assuranceCtx(runtimeData map[string]string) *providers.NodeContext { + return &providers.NodeContext{ + Context: context.Background(), + ExecutionID: "flow-assurance", + RuntimeData: runtimeData, + } +} + +func (suite *AuthAssertExecutorTestSuite) TestCheckAssurance_NoRequirements() { + svcErr := suite.executor.checkAssurance(assuranceCtx(map[string]string{}), suite.executor.logger) + assert.Nil(suite.T(), svcErr) +} + +func (suite *AuthAssertExecutorTestSuite) TestCheckAssurance_AcrMet() { + ctx := assuranceCtx(map[string]string{ + common.RuntimeKeyRequestedAuthClasses: "urn:acr:pwd urn:acr:mfa", + common.RuntimeKeySelectedAuthClass: "urn:acr:mfa", + }) + assert.Nil(suite.T(), suite.executor.checkAssurance(ctx, suite.executor.logger)) +} + +func (suite *AuthAssertExecutorTestSuite) TestCheckAssurance_AcrNotMet() { + ctx := assuranceCtx(map[string]string{ + common.RuntimeKeyRequestedAuthClasses: "urn:acr:mfa", + common.RuntimeKeySelectedAuthClass: "urn:acr:pwd", + }) + svcErr := suite.executor.checkAssurance(ctx, suite.executor.logger) + assert.NotNil(suite.T(), svcErr) + assert.Equal(suite.T(), ErrInteractionRequired.Code, svcErr.Code) +} + +func (suite *AuthAssertExecutorTestSuite) TestCheckAssurance_AcrRequestedButNoneSelected() { + ctx := assuranceCtx(map[string]string{ + common.RuntimeKeyRequestedAuthClasses: "urn:acr:mfa", + }) + svcErr := suite.executor.checkAssurance(ctx, suite.executor.logger) + assert.NotNil(suite.T(), svcErr) + assert.Equal(suite.T(), ErrInteractionRequired.Code, svcErr.Code) +} + +func (suite *AuthAssertExecutorTestSuite) TestCheckAssurance_MaxAgeWithinLimit() { + ctx := assuranceCtx(map[string]string{ + common.RuntimeKeyMaxAge: "3600", + common.RuntimeKeyAuthTime: strconv.FormatInt(time.Now().UTC().Unix()-100, 10), + }) + assert.Nil(suite.T(), suite.executor.checkAssurance(ctx, suite.executor.logger)) +} + +func (suite *AuthAssertExecutorTestSuite) TestCheckAssurance_MaxAgeExceeded() { + ctx := assuranceCtx(map[string]string{ + common.RuntimeKeyMaxAge: "60", + common.RuntimeKeyAuthTime: strconv.FormatInt(time.Now().UTC().Unix()-3600, 10), + }) + svcErr := suite.executor.checkAssurance(ctx, suite.executor.logger) + assert.NotNil(suite.T(), svcErr) + assert.Equal(suite.T(), ErrInteractionRequired.Code, svcErr.Code) +} + +// TestCheckAssurance_MaxAgeFreshAuth covers the fresh-auth path where no auth_time is recorded: +// the subject authenticated in this execution, so max_age is trivially satisfied. +func (suite *AuthAssertExecutorTestSuite) TestCheckAssurance_MaxAgeFreshAuth() { + ctx := assuranceCtx(map[string]string{common.RuntimeKeyMaxAge: "60"}) + assert.Nil(suite.T(), suite.executor.checkAssurance(ctx, suite.executor.logger)) +} + +func (suite *AuthAssertExecutorTestSuite) TestCheckAssurance_MaxAgeMalformedIgnored() { + ctx := assuranceCtx(map[string]string{common.RuntimeKeyMaxAge: "not-a-number"}) + assert.Nil(suite.T(), suite.executor.checkAssurance(ctx, suite.executor.logger)) +} + +// TestExecute_BelowAssurance_InteractionRequired verifies the executor fails with +// interaction_required (rather than issuing an assertion) when the requested acr_values is +// not satisfied. +func (suite *AuthAssertExecutorTestSuite) TestExecute_BelowAssurance_InteractionRequired() { + ctx := &providers.NodeContext{ + Context: context.Background(), + ExecutionID: "flow-assurance", + FlowType: providers.FlowTypeAuthentication, + AuthUser: newCredentialsAuthAuthenticatedUser(), + RuntimeData: map[string]string{ + common.RuntimeKeyRequestedAuthClasses: "urn:acr:mfa", + common.RuntimeKeySelectedAuthClass: "urn:acr:pwd", + }, + } + + resp, err := suite.executor.Execute(ctx) + + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), providers.ExecFailure, resp.Status) + assert.NotNil(suite.T(), resp.Error) + assert.Equal(suite.T(), ErrInteractionRequired.Code, resp.Error.Code) + assert.Empty(suite.T(), resp.Assertion) +} diff --git a/backend/internal/flow/executor/auth_assert_executor.go b/backend/internal/flow/executor/auth_assert_executor.go index f7ffe90770..e53a3f3421 100644 --- a/backend/internal/flow/executor/auth_assert_executor.go +++ b/backend/internal/flow/executor/auth_assert_executor.go @@ -25,6 +25,7 @@ import ( "sort" "strconv" "strings" + "time" tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" @@ -103,6 +104,15 @@ func (a *authAssertExecutor) Execute(ctx *providers.NodeContext) (*providers.Exe } if execResp.AuthUser.IsAuthenticated() { + // Verify the assurance accumulated in this execution (whether from executed nodes or a + // loaded session snapshot) satisfies the request's acr_values and max_age before issuing + // an assertion. + if svcErr := a.checkAssurance(ctx, logger); svcErr != nil { + execResp.Status = providers.ExecFailure + execResp.Error = svcErr + return execResp, nil + } + token, err := a.generateAuthAssertion(ctx, execResp, logger) if err != nil { return nil, err @@ -126,6 +136,51 @@ func (a *authAssertExecutor) Execute(ctx *providers.NodeContext) (*providers.Exe return execResp, nil } +// checkAssurance verifies that the assurance accumulated in this execution satisfies the +// request's acr_values and max_age. It returns ErrInteractionRequired when interaction +// (step-up or re-authentication) is required, or nil when the requirements are met. +func (a *authAssertExecutor) checkAssurance(ctx *providers.NodeContext, + logger *log.Logger) *tidcommon.ServiceError { + // acr_values: the completed authentication class must be one of the requested classes. + requested := strings.Fields(ctx.RuntimeData[common.RuntimeKeyRequestedAuthClasses]) + if len(requested) > 0 { + completed := ctx.RuntimeData[common.RuntimeKeySelectedAuthClass] + if completed == "" || !slices.Contains(requested, completed) { + logger.Debug(ctx.Context, "Accumulated assurance does not satisfy requested acr_values", + log.String("completed", completed)) + return &ErrInteractionRequired + } + } + + // max_age: the subject must have authenticated within max_age seconds. + if rawMaxAge, ok := ctx.RuntimeData[common.RuntimeKeyMaxAge]; ok && rawMaxAge != "" { + maxAge, err := strconv.ParseInt(rawMaxAge, 10, 64) + if err != nil || maxAge < 0 { + // A malformed max_age is treated as no constraint. + logger.Debug(ctx.Context, "Ignoring malformed max_age", log.String("maxAge", rawMaxAge)) + return nil + } + if time.Now().UTC().Unix()-a.resolveAuthTime(ctx) > maxAge { + logger.Debug(ctx.Context, "Authentication is older than max_age; re-authentication required") + return &ErrInteractionRequired + } + } + + return nil +} + +// resolveAuthTime returns the Unix time at which the subject authenticated. On the SSO path +// this comes from the loaded session snapshot; otherwise the subject authenticated during this +// execution, so the current time is used. +func (a *authAssertExecutor) resolveAuthTime(ctx *providers.NodeContext) int64 { + if raw, ok := ctx.RuntimeData[common.RuntimeKeyAuthTime]; ok && raw != "" { + if ts, err := strconv.ParseInt(raw, 10, 64); err == nil { + return ts + } + } + return time.Now().UTC().Unix() +} + // generateAuthAssertion generates the authentication assertion token. func (a *authAssertExecutor) generateAuthAssertion( ctx *providers.NodeContext, execResp *providers.ExecutorResponse, logger *log.Logger, diff --git a/backend/internal/flow/executor/constants.go b/backend/internal/flow/executor/constants.go index fae5e0a133..7fea2ddcab 100644 --- a/backend/internal/flow/executor/constants.go +++ b/backend/internal/flow/executor/constants.go @@ -46,6 +46,8 @@ const ( ExecutorNameAttributeUniquenessValidator = "AttributeUniquenessValidator" ExecutorNameSMSExecutor = "SMSExecutor" ExecutorNameFederatedAuthResolver = "FederatedAuthResolverExecutor" + ExecutorNameSSOCheck = "SSOCheckExecutor" + ExecutorNameSession = "SessionExecutor" ExecutorNameOTPExecutor = "OTPExecutor" ) diff --git a/backend/internal/flow/executor/error_constants.go b/backend/internal/flow/executor/error_constants.go index a153a38fb1..184383a28f 100644 --- a/backend/internal/flow/executor/error_constants.go +++ b/backend/internal/flow/executor/error_constants.go @@ -1158,6 +1158,42 @@ var ( DefaultValue: "User provisioning failed because one or more unique attribute values are already taken", }, } + + // ErrNoLiveSSOSession is returned by the SSO-Check node when no live, compatible session is + // available for the current flow. It is not a hard failure: it routes the node's "Unavailable" + // (onFailure) outcome to the full-authentication path. + ErrNoLiveSSOSession = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "FET-1082", + Error: tidcommon.I18nMessage{ + Key: "flows.executor.errors.no_live_sso_session", + DefaultValue: "No live SSO session", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "flows.executor.errors.no_live_sso_session_desc", + DefaultValue: "No live, compatible SSO session exists for this flow; full authentication is required", + }, + } + + // ErrInteractionRequired is returned when the assurance accumulated in this execution does + // not satisfy the request's acr_values / max_age, so user interaction (step-up or + // re-authentication) is required before an assertion can be issued. Its code maps to the + // OAuth2 `interaction_required` error. + // TODO(sso): wire this to an OAuth2 `interaction_required` authorize-error redirect and + // drive step-up re-authentication. + ErrInteractionRequired = tidcommon.ServiceError{ + Type: tidcommon.ClientErrorType, + Code: "FET-1083", + Error: tidcommon.I18nMessage{ + Key: "flows.executor.errors.interaction_required", + DefaultValue: "Interaction required", + }, + ErrorDescription: tidcommon.I18nMessage{ + Key: "flows.executor.errors.interaction_required_desc", + DefaultValue: "The accumulated authentication assurance does not satisfy the requested " + + "acr_values or max_age", + }, + } ) // errAttributeNotUniqueFor returns a ServiceError for a specific attribute that is not unique. diff --git a/backend/internal/flow/executor/register.go b/backend/internal/flow/executor/register.go index 79cecc311d..01052daca6 100644 --- a/backend/internal/flow/executor/register.go +++ b/backend/internal/flow/executor/register.go @@ -38,6 +38,7 @@ import ( "github.com/thunder-id/thunderid/internal/entityprovider" "github.com/thunder-id/thunderid/internal/entitytype" "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/session" "github.com/thunder-id/thunderid/internal/group" "github.com/thunder-id/thunderid/internal/idp" "github.com/thunder-id/thunderid/internal/notification" @@ -143,6 +144,7 @@ type ExecutorDependencies struct { GithubSvc github.GithubOAuthAuthnServiceInterface GoogleSvc google.GoogleOIDCAuthnServiceInterface OpenID4VPVerifierSvc openid4vp.OpenID4VPServiceInterface + SessionService session.Service } type builtInExecutorRegistrar func(ExecutorRegistryInterface, ExecutorDependencies) @@ -254,6 +256,13 @@ func newBuiltInExecutorRegistrars() map[string]builtInExecutorRegistrar { reg.RegisterExecutor(ExecutorNameOpenID4VPVerify, newOpenID4VPVerifier( deps.FlowFactory, deps.OpenID4VPVerifierSvc, deps.AuthnProvider)) }, + ExecutorNameSSOCheck: func(reg ExecutorRegistryInterface, deps ExecutorDependencies) { + reg.RegisterExecutor(ExecutorNameSSOCheck, newSSOCheckExecutor(deps.FlowFactory, deps.SessionService)) + }, + ExecutorNameSession: func(reg ExecutorRegistryInterface, deps ExecutorDependencies) { + reg.RegisterExecutor(ExecutorNameSession, newSessionExecutor( + deps.FlowFactory, deps.SessionService, deps.AuthnProvider)) + }, ExecutorNameOTPExecutor: func(reg ExecutorRegistryInterface, deps ExecutorDependencies) { reg.RegisterExecutor(ExecutorNameOTPExecutor, newOTPExecutor( deps.FlowFactory, deps.OTPService, deps.AuthnProvider, deps.EntityProvider)) diff --git a/backend/internal/flow/executor/session_executor.go b/backend/internal/flow/executor/session_executor.go new file mode 100644 index 0000000000..122ed793cc --- /dev/null +++ b/backend/internal/flow/executor/session_executor.go @@ -0,0 +1,308 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package executor + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/thunder-id/thunderid/internal/flow/common" + "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/session" + "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// sessionExecutor is the task behind a Session node, which sits at the join where the SSO and +// fresh-authentication branches converge. Its node id is the checkpoint id: on the fresh path it +// saves this checkpoint's session context (establishing the flow execution's session if needed) and +// emits the handle; on the SSO path it loads the checkpoint's saved context into the execution +// context so downstream nodes continue authenticated. A flow may hold several such checkpoints, all +// sharing one session per flow execution. +// +// It is an authentication-type executor: on the SSO path the engine only adopts the loaded +// authenticated user from an authentication executor. All session persistence is delegated to the +// SSO session service; this executor owns only the authn resolution and the flow-context glue. +type sessionExecutor struct { + providers.Executor + sso session.Service + authnProvider providers.AuthnProviderManager + logger *log.Logger +} + +var _ providers.Executor = (*sessionExecutor)(nil) + +// newSessionExecutor creates a new Session executor. The SSO session service wraps all session +// persistence; the authn provider resolves the subject's entity reference when saving and is the +// contract downstream nodes use to read the subject reconstructed on the SSO load path. +func newSessionExecutor(flowFactory core.FlowFactoryInterface, sso session.Service, + authnProvider providers.AuthnProviderManager) *sessionExecutor { + logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "SessionExecutor"), + log.String(log.LoggerKeyExecutorName, ExecutorNameSession)) + + base := flowFactory.CreateExecutor(ExecutorNameSession, providers.ExecutorTypeAuthentication, + []providers.Input{}, []providers.Input{}) + + return &sessionExecutor{ + Executor: base, + sso: sso, + authnProvider: authnProvider, + logger: logger, + } +} + +// Execute saves or loads a checkpoint's session context depending on the SSO-Check decision for this +// join node. The checkpoint id is this node's own id; the paired SSO-Check node names it via +// NodePropertyCheckpointRef. The two paths handle failure asymmetrically: +// - Save (fresh): the user authenticated through this stage's steps, so a save failure only +// forfeits future reuse of this checkpoint — it degrades SSO but must not fail authentication. +// - Load (SSO): SSO-Check committed to skipping this stage on the strength of the resolved +// checkpoint, so the load is the authentication for this run. A load failure leaves no +// authenticated subject and no fallback, so it fails the flow. +// +// All checkpoints of one flow execution share a single session (one handle, one cookie). The first +// join to establish it wins a database-level race keyed by the flow execution id; later joins — on +// any branch, in any request of the execution — attach their checkpoint to that same session. +func (e *sessionExecutor) Execute(ctx *providers.NodeContext) (*providers.ExecutorResponse, error) { + logger := e.logger.With(log.String(log.LoggerKeyExecutionID, ctx.ExecutionID)) + + execResp := &providers.ExecutorResponse{ + Status: providers.ExecComplete, + RuntimeData: make(map[string]string), + } + + checkpoint := ctx.CurrentNodeID + if ctx.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionPresent, checkpoint)] == dataValueTrue { + // A load failure after SSO-Check already skipped the credential steps leaves no authenticated + // subject, so the flow cannot proceed. Return it as a server error: the task-execution node + // logs it and fails the flow. + if err := e.loadCheckpoint(ctx, execResp, checkpoint, logger); err != nil { + return execResp, fmt.Errorf("failed to load SSO checkpoint: %w", err) + } + return execResp, nil + } + + if err := e.saveCheckpoint(ctx, execResp, checkpoint, logger); err != nil { + logger.Error(ctx.Context, "Failed to save SSO checkpoint; continuing without SSO", log.Error(err)) + } + return execResp, nil +} + +// saveCheckpoint resolves the authenticated subject, builds the snapshot from this join's runtime +// state, and hands it to the SSO session service to attach to the flow execution's session. It emits +// the handle for the transport layer to set only when the service minted a new session. +func (e *sessionExecutor) saveCheckpoint(ctx *providers.NodeContext, execResp *providers.ExecutorResponse, + checkpoint string, logger *log.Logger) error { + // Always preserve the already-authenticated user; this executor does not change it on the save + // path, but it is an authentication-type executor so it must echo the AuthUser back to keep the + // engine's authenticated subject. + execResp.AuthUser = ctx.AuthUser + + if e.authnProvider == nil || !ctx.AuthUser.IsAuthenticated() { + logger.Debug(ctx.Context, "No authenticated subject; skipping checkpoint save") + return nil + } + + // Idempotency: if this checkpoint was already saved in this flow execution, re-emit its handle + // instead of saving again. + savedKey := common.SSOCheckpointKey(common.RuntimeKeySSOSessionSaved, checkpoint) + if existing := ctx.RuntimeData[savedKey]; existing != "" { + setHandleOut(execResp, existing) + return nil + } + + // Resolve the subject's entity reference — authn is this executor's responsibility. Its entity id + // keys the session and drives the cross-checkpoint subject-consistency check. The AuthUser is + // snapshotted as-is below; this executor does not materialize attributes or otherwise change it. + _, entityRef, svcErr := e.authnProvider.GetEntityReference(ctx.Context, ctx.AuthUser) + if svcErr != nil { + return fmt.Errorf("failed to resolve subject entity reference: %s", svcErr.ErrorDescription.DefaultValue) + } + if entityRef == nil || entityRef.EntityID == "" { + logger.Debug(ctx.Context, "No resolved subject id; skipping checkpoint save") + return nil + } + + // Snapshot the AuthUser exactly as this flow left it — resolved values stay resolved, lazy tokens + // stay lazy — so the load path replays it verbatim. The RuntimeData snapshot is sanitized of the + // SSO control keys and a small deny-list of request-scoped keys so a replay cannot override the + // joining app's own per-request state (see sanitizeSnapshotRuntimeData). + authUserJSON, err := json.Marshal(&ctx.AuthUser) + if err != nil { + return fmt.Errorf("failed to marshal AuthUser for snapshot: %w", err) + } + + ssoIn := session.SSOInputsFrom(ctx.Context) + result, err := e.sso.SaveCheckpoint(ctx.Context, session.SaveCheckpointInput{ + SubjectID: entityRef.EntityID, + FlowID: ssoIn.FlowID, + FlowVersion: ssoIn.FlowVersion, + ExecutionID: ctx.ExecutionID, + HandleHint: ctx.RuntimeData[common.RuntimeKeySSOSessionHandle], + Checkpoint: checkpoint, + AuthUser: authUserJSON, + RuntimeData: sanitizeSnapshotRuntimeData(ctx.RuntimeData), + CompletedSteps: buildCompletedSteps(ctx.ExecutionHistory), + AppID: ctx.Application.ID, + }) + if err != nil { + return err + } + // The service declined the save because the freshly authenticated subject conflicts with the + // existing session's subject; degrade SSO without failing authentication. + if result.Skipped { + return nil + } + + execResp.RuntimeData[savedKey] = result.Handle + // Publish the session handle as the shared hint so later joins in this execution attach to the + // same session directly. + execResp.RuntimeData[common.RuntimeKeySSOSessionHandle] = result.Handle + // Emit the cookie only when this call minted the session, and only now that its first checkpoint + // is durably saved — so a context-write failure never leaves a cookie for an empty session. + if result.Created { + setHandleOut(execResp, result.Handle) + } + logger.Debug(ctx.Context, "Saved SSO checkpoint", log.String("checkpoint", checkpoint)) + return nil +} + +// setHandleOut records a minted session handle on the response's EngineData channel — engine-only +// output that the flow engine lifts onto the flow step for the transport layer to set the per-flow +// cookie. EngineData is never returned to the client, so the handle does not leak into the response, +// and using a generic channel (not a dedicated field) keeps SSO concepts off the engine contract. +func setHandleOut(execResp *providers.ExecutorResponse, handle string) { + if execResp.EngineData == nil { + execResp.EngineData = make(map[string]string) + } + execResp.EngineData[common.RuntimeKeySSOSessionHandle] = handle +} + +// loadCheckpoint loads a checkpoint's saved flow state into the execution context so downstream +// nodes continue with the authenticated subject and claims. The SSO session service fetches the +// session and its checkpoint context (and refreshes the session's activity); this executor +// rehydrates the subject and replays the snapshotted runtime state. +func (e *sessionExecutor) loadCheckpoint(ctx *providers.NodeContext, execResp *providers.ExecutorResponse, + checkpoint string, logger *log.Logger) error { + handle := ctx.RuntimeData[common.RuntimeKeySSOSessionHandle] + sess, sc, err := e.sso.LoadCheckpoint(ctx.Context, handle, checkpoint, ctx.Application.ID) + if err != nil { + return err + } + + // Rehydrate the AuthUser from the snapshot verbatim — it was stored as-is — so downstream nodes + // continue with the same subject and attributes this session resolved when the checkpoint was saved. + var authUser providers.AuthUser + if err := json.Unmarshal(sc.AuthUser, &authUser); err != nil { + return fmt.Errorf("failed to rehydrate subject reference from snapshot: %w", err) + } + execResp.AuthUser = authUser + + // Replay the snapshotted RuntimeData (the effective attribute set captured at save) so downstream + // nodes see the same attributes the fresh path produced. + for k, v := range sc.RuntimeData { + execResp.RuntimeData[k] = v + } + // auth_time comes from the lean session, not the context. Set it after the RuntimeData replay so + // the live, session-derived value wins over any stale snapshot copy. + if !sess.AuthenticatedAt.IsZero() { + execResp.RuntimeData[common.RuntimeKeyAuthTime] = strconv.FormatInt(sess.AuthenticatedAt.Unix(), 10) + } + + logger.Debug(ctx.Context, "Loaded SSO checkpoint", + log.String("flowId", session.SSOInputsFrom(ctx.Context).FlowID), + log.String("checkpoint", checkpoint)) + return nil +} + +// requestScopedSnapshotDenyList holds request-scoped RuntimeData keys that must not ride along in a +// checkpoint snapshot: they belong to the establishing app's authorization request and, if replayed +// onto a different app joining via SSO, override that app's own attribute/scope requirements (so the +// joining app releases only the establishing app's attributes). This is a local stopgap for the keys +// observed to break attribute release; it should move to a central place when the flow-context data +// classification is implemented. +var requestScopedSnapshotDenyList = map[string]struct{}{ + common.RuntimeKeyRequestedPermissions: {}, + common.RuntimeKeyRequiredEssentialAttributes: {}, + common.RuntimeKeyRequiredOptionalAttributes: {}, + common.RuntimeKeyRequiredLocales: {}, + common.RuntimeKeyClientID: {}, + common.RuntimeKeyAuthorizationRequestID: {}, + // applicationId has no shared constant (set as a raw literal in enrichRuntimeData). + "applicationId": {}, +} + +// sanitizeSnapshotRuntimeData copies RuntimeData for the durable snapshot, dropping the transient SSO +// control keys (the per-checkpoint present/saved flags and the shared handle hint) and the +// request-scoped keys in requestScopedSnapshotDenyList. Persisting the control keys would let a +// reused snapshot reinject a prior run's control state when its RuntimeData is replayed on load; +// persisting the request-scoped keys would override a joining app's own request. RuntimeData is +// otherwise persisted in full pending the flow-context data-classification revisit. Returns nil when +// nothing durable remains. +func sanitizeSnapshotRuntimeData(rd map[string]string) map[string]string { + if len(rd) == 0 { + return nil + } + out := make(map[string]string, len(rd)) + for k, v := range rd { + if _, denied := requestScopedSnapshotDenyList[k]; denied { + continue + } + if k == common.RuntimeKeySSOSessionHandle || + strings.HasPrefix(k, common.RuntimeKeySSOSessionPresent+":") || + strings.HasPrefix(k, common.RuntimeKeySSOSessionSaved+":") { + continue + } + out[k] = v + } + if len(out) == 0 { + return nil + } + return out +} + +// buildCompletedSteps projects the execution history into the bounded per-node step facts kept +// in the session context. Only completed authentication steps are recorded; control/utility nodes +// (START/END, SSO-Check, prompts, authorization) are not authentication-event facts. +func buildCompletedSteps(history map[string]*providers.NodeExecutionRecord) map[string]session.StepFact { + if len(history) == 0 { + return nil + } + steps := make(map[string]session.StepFact) + for nodeID, record := range history { + if record == nil { + continue + } + if record.ExecutorType != providers.ExecutorTypeAuthentication || + record.Status != providers.FlowStatusComplete { + continue + } + steps[nodeID] = session.StepFact{ + Executor: record.ExecutorName, + Status: string(record.Status), + CompletedAt: record.EndTime / 1000, // NodeExecutionRecord.EndTime is Unix millis. + } + } + if len(steps) == 0 { + return nil + } + return steps +} diff --git a/backend/internal/flow/executor/session_executor_test.go b/backend/internal/flow/executor/session_executor_test.go new file mode 100644 index 0000000000..2f2e4dc7ce --- /dev/null +++ b/backend/internal/flow/executor/session_executor_test.go @@ -0,0 +1,395 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package executor + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/flow/common" + "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/session" + "github.com/thunder-id/thunderid/internal/system/cache" + "github.com/thunder-id/thunderid/internal/system/config" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + "github.com/thunder-id/thunderid/tests/mocks/authnprovider/managermock" + "github.com/thunder-id/thunderid/tests/mocks/flow/sessionmock" +) + +type SessionExecutorTestSuite struct { + suite.Suite +} + +func TestSessionExecutorTestSuite(t *testing.T) { + suite.Run(t, new(SessionExecutorTestSuite)) +} + +func (suite *SessionExecutorTestSuite) SetupTest() { + suite.Require().NoError(config.InitializeServerRuntime(suite.T().TempDir(), &config.Config{})) +} + +func (suite *SessionExecutorTestSuite) TearDownTest() { + config.ResetServerRuntime() +} + +func (suite *SessionExecutorTestSuite) newExecutor(sso session.Service, + authn providers.AuthnProviderManager) *sessionExecutor { + flowFactory, _ := core.Initialize(cache.Initialize(config.GetServerRuntime().Config.Cache, "test-deployment")) + return newSessionExecutor(flowFactory, sso, authn) +} + +// saveAuthnMock returns a provider that resolves the fresh-save subject (user-1 / ou-1 / person). +// The expectation is optional so guard-short-circuit tests can reuse it. +func (suite *SessionExecutorTestSuite) saveAuthnMock() *managermock.AuthnProviderManagerMock { + m := managermock.NewAuthnProviderManagerMock(suite.T()) + resolved := authenticatedAuthUser() + m.On("GetEntityReference", mock.Anything, mock.Anything). + Return(resolved, &providers.EntityReference{EntityID: "user-1", OUID: "ou-1", EntityType: "person"}, nil). + Maybe() + return m +} + +// authenticatedAuthUser returns an AuthUser that reports IsAuthenticated() == true. Its tokens +// are opaque; the resolved subject is supplied by the mocked provider in each test. +func authenticatedAuthUser() providers.AuthUser { + var authUser providers.AuthUser + if err := authUser.UnmarshalJSON([]byte(`{"entityReferenceToken":"tok","attributeToken":"tok"}`)); err != nil { + panic("authenticatedAuthUser: malformed hardcoded JSON: " + err.Error()) + } + return authUser +} + +func freshCtx() *providers.NodeContext { + return &providers.NodeContext{ + Context: session.WithSSOInputs(context.Background(), + session.SSOInputs{FlowID: "flow-1", FlowVersion: 3}), + ExecutionID: "exec-1", + CurrentNodeID: "session", + RuntimeData: map[string]string{ + "email": "alice@example.com", + // An attribute derived by an in-flow step: it must survive the snapshot verbatim so the + // SSO path reproduces it without re-running the step. + "department": "eng", + }, + AuthUser: authenticatedAuthUser(), + ExecutionHistory: map[string]*providers.NodeExecutionRecord{ + // Control node: must be excluded from the auth-event facts. + "sso_check": {NodeID: "sso_check", ExecutorName: "SSOCheckExecutor", + ExecutorType: providers.ExecutorTypeUtility, Status: providers.FlowStatusComplete, + EndTime: 1700000000000}, + // Completed authentication step: recorded with its completion time (ms → s). + "basic_auth": {NodeID: "basic_auth", ExecutorName: "CredentialsAuthExecutor", + ExecutorType: providers.ExecutorTypeAuthentication, Status: providers.FlowStatusComplete, + EndTime: 1700000005000}, + }, + Application: providers.Application{ID: "app-123"}, + } +} + +// captureSave configures the service mock to record the save input and return the given result. +func captureSave(sso *sessionmock.ServiceMock, captured *session.SaveCheckpointInput, + result session.SaveCheckpointResult) { + sso.EXPECT().SaveCheckpoint(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, in session.SaveCheckpointInput) (session.SaveCheckpointResult, error) { + *captured = in + return result, nil + }) +} + +// TestFreshSave verifies the save path hands the SSO service a correctly-built snapshot and, on a +// freshly minted session, publishes the handle to the transport (EngineData) and the shared +// RuntimeData hints. +func (suite *SessionExecutorTestSuite) TestFreshSave() { + sso := sessionmock.NewServiceMock(suite.T()) + var in session.SaveCheckpointInput + captureSave(sso, &in, session.SaveCheckpointResult{Handle: "handle-xyz", Created: true}) + exec := suite.newExecutor(sso, suite.saveAuthnMock()) + + resp, err := exec.Execute(freshCtx()) + suite.Require().NoError(err) + + // The executor resolved the subject and handed the service a complete save input. + suite.Equal("user-1", in.SubjectID) + suite.Equal("flow-1", in.FlowID) + suite.Equal(3, in.FlowVersion) + suite.Equal("exec-1", in.ExecutionID) + suite.Equal("session", in.Checkpoint) + suite.Equal("app-123", in.AppID) + // RuntimeData snapshot carries the in-flow-derived attribute. + suite.Equal("alice@example.com", in.RuntimeData["email"]) + suite.Equal("eng", in.RuntimeData["department"]) + // AuthUser is snapshotted as-is and round-trips to the authenticated subject. + var snapAuthUser providers.AuthUser + suite.Require().NoError(json.Unmarshal(in.AuthUser, &snapAuthUser)) + suite.True(snapAuthUser.IsAuthenticated()) + // Only the completed authentication step is recorded (control node excluded), ms → s. + suite.Contains(in.CompletedSteps, "basic_auth") + suite.NotContains(in.CompletedSteps, "sso_check") + suite.Equal(int64(1700000005), in.CompletedSteps["basic_auth"].CompletedAt) + + // A minted session emits the handle to the transport (EngineData, never returned to the client) + // and records it per-checkpoint for idempotency plus as the shared hint. + suite.Equal("handle-xyz", resp.EngineData[common.RuntimeKeySSOSessionHandle]) + suite.Equal("handle-xyz", + resp.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionSaved, "session")]) + suite.Equal("handle-xyz", resp.RuntimeData[common.RuntimeKeySSOSessionHandle]) + // The already-authenticated subject is echoed back so the engine keeps it. + suite.True(resp.AuthUser.IsAuthenticated()) +} + +// TestFreshSave_AttachNoCookie covers attaching to an existing session (service reports +// Created=false): the handle is recorded on RuntimeData but no cookie is emitted. +func (suite *SessionExecutorTestSuite) TestFreshSave_AttachNoCookie() { + sso := sessionmock.NewServiceMock(suite.T()) + var in session.SaveCheckpointInput + captureSave(sso, &in, session.SaveCheckpointResult{Handle: "handle-abc", Created: false}) + exec := suite.newExecutor(sso, suite.saveAuthnMock()) + ctx := freshCtx() + ctx.CurrentNodeID = "step_up" + ctx.RuntimeData[common.RuntimeKeySSOSessionHandle] = "handle-abc" + + resp, err := exec.Execute(ctx) + suite.Require().NoError(err) + + suite.Equal("handle-abc", in.HandleHint, "the shared handle hint is passed to the service") + suite.Empty(resp.EngineData[common.RuntimeKeySSOSessionHandle], "no cookie is emitted when attaching") + suite.Equal("handle-abc", + resp.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionSaved, "step_up")]) +} + +// TestFreshSave_SkippedNoEmission covers the service declining the save (subject conflict): nothing is +// emitted or recorded. +func (suite *SessionExecutorTestSuite) TestFreshSave_SkippedNoEmission() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().SaveCheckpoint(mock.Anything, mock.Anything). + Return(session.SaveCheckpointResult{Skipped: true}, nil) + exec := suite.newExecutor(sso, suite.saveAuthnMock()) + + resp, err := exec.Execute(freshCtx()) + suite.Require().NoError(err) + + suite.Empty(resp.EngineData[common.RuntimeKeySSOSessionHandle]) + suite.Empty(resp.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionSaved, "session")]) +} + +// TestFreshSave_SaveErrorIsNonFatal covers a service error on save: it degrades SSO without failing +// authentication. +func (suite *SessionExecutorTestSuite) TestFreshSave_SaveErrorIsNonFatal() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().SaveCheckpoint(mock.Anything, mock.Anything). + Return(session.SaveCheckpointResult{}, errors.New("db down")) + exec := suite.newExecutor(sso, suite.saveAuthnMock()) + + resp, err := exec.Execute(freshCtx()) + + suite.Require().NoError(err) + suite.Equal(providers.ExecComplete, resp.Status) + suite.Empty(resp.EngineData[common.RuntimeKeySSOSessionHandle]) +} + +// TestFreshSave_Idempotent covers a checkpoint already saved in this execution: the handle is +// re-emitted from RuntimeData without calling the service (no SaveCheckpoint expectation is set). +func (suite *SessionExecutorTestSuite) TestFreshSave_Idempotent() { + sso := sessionmock.NewServiceMock(suite.T()) + exec := suite.newExecutor(sso, suite.saveAuthnMock()) + ctx := freshCtx() + ctx.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionSaved, "session")] = "existing-handle" + + resp, err := exec.Execute(ctx) + suite.Require().NoError(err) + + suite.Equal("existing-handle", resp.EngineData[common.RuntimeKeySSOSessionHandle]) +} + +// TestFreshSave_Unauthenticated covers no authenticated subject: the service is never called. +func (suite *SessionExecutorTestSuite) TestFreshSave_Unauthenticated() { + sso := sessionmock.NewServiceMock(suite.T()) + exec := suite.newExecutor(sso, suite.saveAuthnMock()) + ctx := freshCtx() + ctx.AuthUser = providers.AuthUser{} + + resp, err := exec.Execute(ctx) + suite.Require().NoError(err) + + suite.Empty(resp.EngineData[common.RuntimeKeySSOSessionHandle]) +} + +// TestFreshSave_EntityReferenceErrorIsNonFatal covers a subject-resolution failure: the save is +// skipped (service never called) without failing authentication. +func (suite *SessionExecutorTestSuite) TestFreshSave_EntityReferenceErrorIsNonFatal() { + m := managermock.NewAuthnProviderManagerMock(suite.T()) + m.EXPECT().GetEntityReference(mock.Anything, mock.Anything). + Return(authenticatedAuthUser(), nil, &ErrNoLiveSSOSession) + sso := sessionmock.NewServiceMock(suite.T()) + exec := suite.newExecutor(sso, m) + + resp, err := exec.Execute(freshCtx()) + + suite.Require().NoError(err) + suite.Equal(providers.ExecComplete, resp.Status) +} + +// TestFreshSave_NoResolvedSubjectSkips covers a resolved reference with no entity id: the save is +// skipped without calling the service. +func (suite *SessionExecutorTestSuite) TestFreshSave_NoResolvedSubjectSkips() { + m := managermock.NewAuthnProviderManagerMock(suite.T()) + m.EXPECT().GetEntityReference(mock.Anything, mock.Anything). + Return(authenticatedAuthUser(), &providers.EntityReference{EntityID: ""}, nil) + sso := sessionmock.NewServiceMock(suite.T()) + exec := suite.newExecutor(sso, m) + + resp, err := exec.Execute(freshCtx()) + + suite.Require().NoError(err) + suite.Equal(providers.ExecComplete, resp.Status) +} + +// TestFreshSave_SanitizesSnapshot verifies the input the executor hands the service excludes both the +// transient SSO control keys and the request-scoped keys. +func (suite *SessionExecutorTestSuite) TestFreshSave_SanitizesSnapshot() { + sso := sessionmock.NewServiceMock(suite.T()) + var in session.SaveCheckpointInput + captureSave(sso, &in, session.SaveCheckpointResult{Handle: "h", Created: true}) + exec := suite.newExecutor(sso, suite.saveAuthnMock()) + + ctx := freshCtx() + ctx.RuntimeData[common.RuntimeKeySSOSessionHandle] = "some-handle" + ctx.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionPresent, "other")] = dataValueTrue + ctx.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionSaved, "other")] = "h" + ctx.RuntimeData[common.RuntimeKeyRequiredEssentialAttributes] = "email" + ctx.RuntimeData[common.RuntimeKeyRequiredOptionalAttributes] = "phone" + ctx.RuntimeData[common.RuntimeKeyRequiredLocales] = "en-US" + ctx.RuntimeData[common.RuntimeKeyRequestedPermissions] = "openid profile" + ctx.RuntimeData["applicationId"] = "app-a" + ctx.RuntimeData[common.RuntimeKeyClientID] = "sso_app_a" + ctx.RuntimeData[common.RuntimeKeyAuthorizationRequestID] = "authz-req-1" + + _, err := exec.Execute(ctx) + suite.Require().NoError(err) + + rd := in.RuntimeData + // Durable business data survives. + suite.Equal("alice@example.com", rd["email"]) + suite.Equal("eng", rd["department"]) + // Transient SSO control keys are stripped. + suite.NotContains(rd, common.RuntimeKeySSOSessionHandle) + suite.NotContains(rd, common.SSOCheckpointKey(common.RuntimeKeySSOSessionPresent, "other")) + suite.NotContains(rd, common.SSOCheckpointKey(common.RuntimeKeySSOSessionSaved, "other")) + // Request-scoped keys are stripped so they cannot override a joining app's request. + suite.NotContains(rd, common.RuntimeKeyRequiredEssentialAttributes) + suite.NotContains(rd, common.RuntimeKeyRequiredOptionalAttributes) + suite.NotContains(rd, common.RuntimeKeyRequiredLocales) + suite.NotContains(rd, common.RuntimeKeyRequestedPermissions) + suite.NotContains(rd, "applicationId") + suite.NotContains(rd, common.RuntimeKeyClientID) + suite.NotContains(rd, common.RuntimeKeyAuthorizationRequestID) +} + +func ssoLoadCtx() *providers.NodeContext { + return &providers.NodeContext{ + Context: session.WithSSOInputs(context.Background(), + session.SSOInputs{FlowID: "flow-1", FlowVersion: 3}), + ExecutionID: "exec-2", + CurrentNodeID: "session", + RuntimeData: map[string]string{ + common.SSOCheckpointKey(common.RuntimeKeySSOSessionPresent, "session"): dataValueTrue, + common.RuntimeKeySSOSessionHandle: "handle-abc", + }, + Application: providers.Application{ID: "app-456"}, + } +} + +// TestSSOLoad verifies the load path rehydrates the subject from the service-returned context, replays +// the snapshotted RuntimeData, and overrides auth_time from the lean session. +func (suite *SessionExecutorTestSuite) TestSSOLoad() { + snapAuthUser := `{"entityReference":{"entityId":"user-2","ouId":"ou-9","type":"person"},` + + `"attributes":{"attributes":{"email":{"value":"bob@example.com"}}}}` + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().LoadCheckpoint(mock.Anything, "handle-abc", "session", "app-456").Return( + &session.Session{ + SessionID: "sess-1", SubjectID: "user-2", HandleID: "handle-abc", + AuthenticatedAt: time.Unix(1700000000, 0).UTC(), + }, + &session.SessionContext{ + SessionID: "sess-1", + RuntimeData: map[string]string{ + "email": "bob@example.com", + "department": "eng", + // A stale auth_time in the snapshot must be overridden by the session's value on load. + common.RuntimeKeyAuthTime: "1600000000", + }, + AuthUser: json.RawMessage(snapAuthUser), + ContextVersion: 1, + }, nil) + // The load path rehydrates the subject from the snapshot and never calls the provider. + exec := suite.newExecutor(sso, managermock.NewAuthnProviderManagerMock(suite.T())) + + resp, err := exec.Execute(ssoLoadCtx()) + suite.Require().NoError(err) + + // The AuthUser is rehydrated verbatim from the snapshot. + suite.True(resp.AuthUser.IsAuthenticated()) + au := resp.AuthUser + raw, marshalErr := json.Marshal(&au) + suite.Require().NoError(marshalErr) + rendered := string(raw) + suite.True(strings.Contains(rendered, `"entityId":"user-2"`), rendered) + suite.True(strings.Contains(rendered, "bob@example.com"), rendered) + // The snapshotted RuntimeData is replayed, including the in-flow-derived attribute. + suite.Equal("bob@example.com", resp.RuntimeData["email"]) + suite.Equal("eng", resp.RuntimeData["department"]) + // auth_time comes from the lean session and wins over the stale snapshot copy. + suite.Equal("1700000000", resp.RuntimeData[common.RuntimeKeyAuthTime]) +} + +// TestSSOLoad_ErrorFailsFlow covers a load failure surfacing as a server error so the task-execution +// node fails the flow (the credential steps were already skipped). +func (suite *SessionExecutorTestSuite) TestSSOLoad_ErrorFailsFlow() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().LoadCheckpoint(mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, nil, errors.New("resolved session no longer exists")) + exec := suite.newExecutor(sso, managermock.NewAuthnProviderManagerMock(suite.T())) + + _, err := exec.Execute(ssoLoadCtx()) + + suite.Require().Error(err) + suite.Contains(err.Error(), "failed to load SSO checkpoint") +} + +// TestSSOLoad_RehydrateErrorFailsFlow covers an unparseable AuthUser snapshot: the executor cannot +// reconstruct the subject, so the flow fails. +func (suite *SessionExecutorTestSuite) TestSSOLoad_RehydrateErrorFailsFlow() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().LoadCheckpoint(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + &session.Session{SessionID: "sess-1", HandleID: "handle-abc"}, + &session.SessionContext{SessionID: "sess-1", AuthUser: json.RawMessage("not-json")}, nil) + exec := suite.newExecutor(sso, managermock.NewAuthnProviderManagerMock(suite.T())) + + _, err := exec.Execute(ssoLoadCtx()) + + suite.Require().Error(err) + suite.Contains(err.Error(), "failed to load SSO checkpoint") +} diff --git a/backend/internal/flow/executor/sso_check_executor.go b/backend/internal/flow/executor/sso_check_executor.go new file mode 100644 index 0000000000..a789f0ee76 --- /dev/null +++ b/backend/internal/flow/executor/sso_check_executor.go @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package executor + +import ( + "time" + + "github.com/thunder-id/thunderid/internal/flow/common" + "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/session" + "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// ssoCheckExecutor resolves whether a live, compatible SSO session exists for the current flow and +// records the decision. It is the task behind the SSO-Check node and routes the Skip/Authenticate +// outcomes. It holds only the SSO session service, never the stores directly. +type ssoCheckExecutor struct { + providers.Executor + sso session.Service + logger *log.Logger +} + +var _ providers.Executor = (*ssoCheckExecutor)(nil) + +// newSSOCheckExecutor creates a new SSO-Check executor backed by the SSO session service. +func newSSOCheckExecutor(flowFactory core.FlowFactoryInterface, sso session.Service) *ssoCheckExecutor { + logger := log.GetLogger().With(log.String(log.LoggerKeyComponentName, "SSOCheckExecutor"), + log.String(log.LoggerKeyExecutorName, ExecutorNameSSOCheck)) + + base := flowFactory.CreateExecutor(ExecutorNameSSOCheck, providers.ExecutorTypeUtility, + []providers.Input{}, []providers.Input{}) + + return &ssoCheckExecutor{ + Executor: base, + sso: sso, + logger: logger, + } +} + +// Execute routes this SSO-Check node's two outcomes for its checkpoint (the Session node id named by +// NodePropertyCheckpointRef): +// - Skip (a live session that already holds this checkpoint's snapshot): COMPLETE → onSuccess; +// records the checkpoint-present flag and the shared session handle so the paired Session node +// loads the saved flow state. +// - Authenticate (no live session, or the session lacks this checkpoint): FAILURE → onFailure, +// sending the flow down the full-authentication path for this stage. When a live session exists +// but lacks the checkpoint, the handle is still shared so the fresh join attaches its new +// checkpoint to that same session. This is a routing outcome, not a hard error. +func (e *ssoCheckExecutor) Execute(ctx *providers.NodeContext) (*providers.ExecutorResponse, error) { + logger := e.logger.With(log.String(log.LoggerKeyExecutionID, ctx.ExecutionID)) + + execResp := &providers.ExecutorResponse{ + RuntimeData: make(map[string]string), + } + + checkpoint := checkpointRef(ctx) + presentKey := common.SSOCheckpointKey(common.RuntimeKeySSOSessionPresent, checkpoint) + + in := session.SSOInputsFrom(ctx.Context) + resolved, err := e.sso.Resolve(ctx.Context, in.Handle, in.FlowID, in.FlowVersion, time.Now().UTC()) + if err != nil { + return execResp, err + } + if resolved != nil { + // A live session exists; share its handle so a fresh join attaches to it even when this + // checkpoint is not yet present. + execResp.RuntimeData[common.RuntimeKeySSOSessionHandle] = resolved.HandleID + } + + present := false + if resolved != nil && checkpoint != "" { + if present, err = e.sso.HasCheckpoint(ctx.Context, resolved.SessionID, checkpoint); err != nil { + return execResp, err + } + } + + if present { + execResp.Status = providers.ExecComplete + execResp.RuntimeData[presentKey] = dataValueTrue + logger.Debug(ctx.Context, "Live SSO checkpoint present; routing to the Skip outcome", + log.String("flowId", in.FlowID), + log.String("checkpoint", checkpoint)) + } else { + execResp.Status = providers.ExecFailure + execResp.Error = &ErrNoLiveSSOSession + execResp.RuntimeData[presentKey] = "false" + logger.Debug(ctx.Context, "No reusable SSO checkpoint; routing to the Authenticate outcome", + log.String("checkpoint", checkpoint)) + } + + return execResp, nil +} + +// checkpointRef returns the Session (join) node id this SSO-Check node guards, read from +// NodePropertyCheckpointRef. An empty value means the node is not paired with a checkpoint, which +// routes to the Authenticate outcome. +func checkpointRef(ctx *providers.NodeContext) string { + if ctx.NodeProperties == nil { + return "" + } + if v, ok := ctx.NodeProperties[common.NodePropertyCheckpointRef].(string); ok { + return v + } + return "" +} diff --git a/backend/internal/flow/executor/sso_check_executor_test.go b/backend/internal/flow/executor/sso_check_executor_test.go new file mode 100644 index 0000000000..36b1747518 --- /dev/null +++ b/backend/internal/flow/executor/sso_check_executor_test.go @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package executor + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/flow/common" + "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/session" + "github.com/thunder-id/thunderid/internal/system/cache" + "github.com/thunder-id/thunderid/internal/system/config" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + "github.com/thunder-id/thunderid/tests/mocks/flow/sessionmock" +) + +type SSOCheckExecutorTestSuite struct { + suite.Suite +} + +func TestSSOCheckExecutorTestSuite(t *testing.T) { + suite.Run(t, new(SSOCheckExecutorTestSuite)) +} + +func (suite *SSOCheckExecutorTestSuite) SetupTest() { + suite.Require().NoError(config.InitializeServerRuntime(suite.T().TempDir(), &config.Config{})) +} + +func (suite *SSOCheckExecutorTestSuite) TearDownTest() { + config.ResetServerRuntime() +} + +func (suite *SSOCheckExecutorTestSuite) newExecutor(sso session.Service) *ssoCheckExecutor { + flowFactory, _ := core.Initialize(cache.Initialize(config.GetServerRuntime().Config.Cache, "test-deployment")) + return newSSOCheckExecutor(flowFactory, sso) +} + +func ssoNodeContext() *providers.NodeContext { + return &providers.NodeContext{ + Context: session.WithSSOInputs(context.Background(), session.SSOInputs{ + Handle: "handle-abc", + FlowID: "flow-1", + FlowVersion: 3, + }), + ExecutionID: "exec-1", + NodeProperties: map[string]interface{}{common.NodePropertyCheckpointRef: "session"}, + } +} + +func liveSession() *session.Session { + return &session.Session{ + SessionID: "sess-1", + HandleID: "handle-abc", + FlowID: "flow-1", + FlowVersion: 3, + State: session.StateActive, + } +} + +// assertAbsent asserts the Authenticate outcome: the node fails (routing to onFailure) with the +// no-live-session error, records the decision, and stashes no handle. +func (suite *SSOCheckExecutorTestSuite) assertAbsent(resp *providers.ExecutorResponse) { + suite.Equal(providers.ExecFailure, resp.Status) + suite.Require().NotNil(resp.Error) + suite.Equal(ErrNoLiveSSOSession.Code, resp.Error.Code) + suite.Equal("false", + resp.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionPresent, "session")]) + suite.Empty(resp.RuntimeData[common.RuntimeKeySSOSessionHandle]) +} + +// TestPresent covers a live session that already holds this checkpoint: routes to Skip and shares the +// handle so the paired Session node loads the saved state. +func (suite *SSOCheckExecutorTestSuite) TestPresent() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Resolve(mock.Anything, "handle-abc", "flow-1", 3, mock.Anything).Return(liveSession(), nil) + sso.EXPECT().HasCheckpoint(mock.Anything, "sess-1", "session").Return(true, nil) + exec := suite.newExecutor(sso) + + resp, err := exec.Execute(ssoNodeContext()) + + suite.Require().NoError(err) + suite.Equal(providers.ExecComplete, resp.Status) + suite.Equal(dataValueTrue, + resp.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionPresent, "session")]) + suite.Equal("handle-abc", resp.RuntimeData[common.RuntimeKeySSOSessionHandle]) +} + +// TestAbsentCheckpointNotPresent covers a live session that lacks this checkpoint: the node routes to +// Authenticate so the stage authenticates fresh, but still shares the session handle so the fresh join +// attaches its new checkpoint to that same session. +func (suite *SSOCheckExecutorTestSuite) TestAbsentCheckpointNotPresent() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Resolve(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(liveSession(), nil) + sso.EXPECT().HasCheckpoint(mock.Anything, "sess-1", "session").Return(false, nil) + exec := suite.newExecutor(sso) + + resp, err := exec.Execute(ssoNodeContext()) + + suite.Require().NoError(err) + suite.Equal(providers.ExecFailure, resp.Status) + suite.Equal("false", + resp.RuntimeData[common.SSOCheckpointKey(common.RuntimeKeySSOSessionPresent, "session")]) + // The handle is still shared because a live session exists. + suite.Equal("handle-abc", resp.RuntimeData[common.RuntimeKeySSOSessionHandle]) +} + +// TestAbsentNoLiveSession covers the service resolving no live session (nil): the node routes to +// Authenticate and shares no handle. +func (suite *SSOCheckExecutorTestSuite) TestAbsentNoLiveSession() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Resolve(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, nil) + exec := suite.newExecutor(sso) + + resp, err := exec.Execute(ssoNodeContext()) + + suite.Require().NoError(err) + suite.assertAbsent(resp) +} + +// TestNoCheckpointRef covers a node not paired with a checkpoint: it routes to Authenticate without a +// checkpoint lookup. +func (suite *SSOCheckExecutorTestSuite) TestNoCheckpointRef() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Resolve(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, nil) + exec := suite.newExecutor(sso) + ctx := ssoNodeContext() + ctx.NodeProperties = nil + + resp, err := exec.Execute(ctx) + + suite.Require().NoError(err) + suite.Equal(providers.ExecFailure, resp.Status) +} + +// TestResolverErrorFailsFlow covers a session-resolution store failure surfacing from the service: +// Execute returns a Go error for the task-execution node to log and fail the flow. +func (suite *SSOCheckExecutorTestSuite) TestResolverErrorFailsFlow() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Resolve(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, errors.New("store down")) + exec := suite.newExecutor(sso) + + _, err := exec.Execute(ssoNodeContext()) + + suite.Require().Error(err) + suite.Contains(err.Error(), "store down") +} + +// TestCheckpointListErrorFailsFlow covers a checkpoint-existence lookup failure on a live session: +// Execute returns a Go error rather than skipping on incomplete information. +func (suite *SSOCheckExecutorTestSuite) TestCheckpointListErrorFailsFlow() { + sso := sessionmock.NewServiceMock(suite.T()) + sso.EXPECT().Resolve(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(liveSession(), nil) + sso.EXPECT().HasCheckpoint(mock.Anything, mock.Anything, mock.Anything). + Return(false, errors.New("store down")) + exec := suite.newExecutor(sso) + + _, err := exec.Execute(ssoNodeContext()) + + suite.Require().Error(err) + suite.Contains(err.Error(), "store down") +} + +func (suite *SSOCheckExecutorTestSuite) TestCheckpointRef() { + // Nil properties, missing key, and non-string values all resolve to no checkpoint. + suite.Empty(checkpointRef(&providers.NodeContext{})) + suite.Empty(checkpointRef(&providers.NodeContext{NodeProperties: map[string]interface{}{}})) + suite.Empty(checkpointRef(&providers.NodeContext{ + NodeProperties: map[string]interface{}{common.NodePropertyCheckpointRef: 42}, + })) + // A string value is returned as the checkpoint id. + suite.Equal("session", checkpointRef(&providers.NodeContext{ + NodeProperties: map[string]interface{}{common.NodePropertyCheckpointRef: "session"}, + })) +} diff --git a/backend/internal/flow/flowexec/engine.go b/backend/internal/flow/flowexec/engine.go index b913920b28..53d819c45d 100644 --- a/backend/internal/flow/flowexec/engine.go +++ b/backend/internal/flow/flowexec/engine.go @@ -33,6 +33,7 @@ import ( "github.com/thunder-id/thunderid/internal/flow/core" "github.com/thunder-id/thunderid/internal/flow/executor" "github.com/thunder-id/thunderid/internal/flow/graphbuilder" + "github.com/thunder-id/thunderid/internal/flow/session" "github.com/thunder-id/thunderid/internal/system/log" "github.com/thunder-id/thunderid/internal/system/observability/event" sysutils "github.com/thunder-id/thunderid/internal/system/utils" @@ -171,8 +172,15 @@ func (fe *flowEngine) executeNodePackage(ctx *EngineContext, logger.Debug(ctx.Context, "Executing node") + // SSO inputs ride on the context (transient, never persisted, and off the engine contract); + // only the SSO-Check and Session nodes read them. + ssoCtx := session.WithSSOInputs(ctx.Context, session.SSOInputs{ + Handle: ctx.SSOHandleIn, + FlowID: ssoFlowID(ctx), + FlowVersion: ctx.SSOFlowVersion, + }) nodeCtx := &providers.NodeContext{ - Context: ctx.Context, + Context: ssoCtx, ExecutionID: ctx.ExecutionID, FlowType: ctx.FlowType, EntityID: ctx.AppID, @@ -788,6 +796,15 @@ func (fe *flowEngine) processNodeResponse(ctx *EngineContext, nodeResp *common.N return nil, false, &tidcommon.InternalServerError } + // Carry any SSO handle minted by this node onto the flow step so the transport layer can emit + // it. The Session node emits the handle on the engine-only EngineData channel (never returned to + // the client and off the engine contract). Stamped here (not only at completion) so it survives + // an immediately following prompt step that returns the flow as incomplete. + if handle := nodeResp.EngineData[common.RuntimeKeySSOSessionHandle]; handle != "" { + flowStep.SSOHandleOut = handle + flowStep.SSOFlowID = ssoFlowID(ctx) + } + switch nodeResp.Status { case common.NodeStatusComplete: if fe.isDisplayOnlyPromptNode(ctx.CurrentNode) { @@ -1606,3 +1623,12 @@ func processNodeResponseErrorForEventPublish(nodeResp *common.NodeResponse) map[ }, } } + +// ssoFlowID returns the current flow's ID (used as the SSO group key), or "" if no graph +// is set on the context. +func ssoFlowID(ctx *EngineContext) string { + if ctx == nil || ctx.Graph == nil { + return "" + } + return ctx.Graph.GetID() +} diff --git a/backend/internal/flow/flowexec/engine_test.go b/backend/internal/flow/flowexec/engine_test.go index 26e12350ad..3843338523 100644 --- a/backend/internal/flow/flowexec/engine_test.go +++ b/backend/internal/flow/flowexec/engine_test.go @@ -2589,6 +2589,7 @@ func (s *EngineTestSuite) TestExecuteNodePackage_IncompleteExitClearsNodeScopeBe mockGraph := coremock.NewGraphInterfaceMock(t) mockGraph.On("HasSegments").Return(false).Maybe() + mockGraph.On("GetID").Return("flow-incomplete").Maybe() mockGraph.On("GetInterceptors", providers.InterceptorModePreNode). Return([]core.InterceptorUnitInterface{unit}).Maybe() mockGraph.On("GetInterceptors", providers.InterceptorModePostNode). diff --git a/backend/internal/flow/flowexec/handler.go b/backend/internal/flow/flowexec/handler.go index 75d9a49e1b..503ad506b0 100644 --- a/backend/internal/flow/flowexec/handler.go +++ b/backend/internal/flow/flowexec/handler.go @@ -21,7 +21,9 @@ package flowexec import ( "context" "net/http" + "time" + "github.com/thunder-id/thunderid/internal/flow/session" tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" serverconst "github.com/thunder-id/thunderid/internal/system/constants" @@ -33,11 +35,17 @@ import ( // FlowExecutionHandler handles flow execution requests. type flowExecutionHandler struct { flowExecService FlowExecServiceInterface + ssoTransport session.HandleTransport + // ssoHandleTTL bounds the per-flow SSO handle cookie to the session's configured absolute lifetime. + ssoHandleTTL time.Duration } -func newFlowExecutionHandler(flowExecService FlowExecServiceInterface) *flowExecutionHandler { +func newFlowExecutionHandler(flowExecService FlowExecServiceInterface, ssoTransport session.HandleTransport, + ssoHandleTTL time.Duration) *flowExecutionHandler { return &flowExecutionHandler{ flowExecService: flowExecService, + ssoTransport: ssoTransport, + ssoHandleTTL: ssoHandleTTL, } } @@ -61,8 +69,12 @@ func (h *flowExecutionHandler) HandleFlowExecutionRequest(w http.ResponseWriter, challengeToken := sysutils.SanitizeString(flowR.ChallengeToken) flowSecret := sysutils.SanitizeString(r.Header.Get(serverconst.FlowSecretHeaderName)) + // Read the inbound SSO transport inputs (per-flow handle cookies) and make + // them available to the flow service, which selects the handle once the flow is known. + ctx := session.WithInbound(r.Context(), h.ssoTransport.Read(r)) + flowStep, flowErr := h.flowExecService.Execute( - r.Context(), appID, executionID, flowTypeStr, verbose, action, inputs, challengeToken, flowSecret) + ctx, appID, executionID, flowTypeStr, verbose, action, inputs, challengeToken, flowSecret) if flowErr != nil { handleFlowError(r.Context(), w, flowErr) @@ -76,6 +88,15 @@ func (h *flowExecutionHandler) HandleFlowExecutionRequest(w http.ResponseWriter, stepErrorResp = &resp } + // Emit the per-flow SSO handle cookie when the flow minted a new session handle. This must + // happen before the response body is written. + if flowStep.SSOHandleOut != "" && flowStep.SSOFlowID != "" { + // The handle has no TTL of its own; bound the cookie to the session's configured absolute + // lifetime. + h.ssoTransport.Write(w, session.CookieName(flowStep.SSOFlowID), flowStep.SSOHandleOut, + h.ssoHandleTTL) + } + flowResp := FlowResponse{ ExecutionID: flowStep.ExecutionID, StepID: flowStep.StepID, diff --git a/backend/internal/flow/flowexec/handler_test.go b/backend/internal/flow/flowexec/handler_test.go index ca19f5299e..3223b842d8 100644 --- a/backend/internal/flow/flowexec/handler_test.go +++ b/backend/internal/flow/flowexec/handler_test.go @@ -24,7 +24,9 @@ import ( "net/http" "net/http/httptest" "testing" + "time" + "github.com/thunder-id/thunderid/internal/flow/session" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common" @@ -46,7 +48,7 @@ func TestHandlerTestSuite(t *testing.T) { func (s *HandlerTestSuite) TestNewFlowExecutionHandler() { t := s.T() mockSvc := NewFlowExecServiceInterfaceMock(t) - h := newFlowExecutionHandler(mockSvc) + h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) s.NotNil(h) s.Equal(mockSvc, h.flowExecService) } @@ -104,7 +106,7 @@ func (s *HandlerTestSuite) TestConvertToAPIError() { func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_InvalidJSON() { t := s.T() mockSvc := NewFlowExecServiceInterfaceMock(t) - h := newFlowExecutionHandler(mockSvc) + h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) req := httptest.NewRequest(http.MethodPost, "/flow/execute", bytes.NewBufferString("not-json")) req.Header.Set("Content-Type", "application/json") @@ -121,7 +123,7 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_ServiceError() { mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(nil, &ErrorDirectFlowInitiationNotPermitted) - h := newFlowExecutionHandler(mockSvc) + h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) req := httptest.NewRequest(http.MethodPost, "/flow/execute", bytes.NewBufferString(testFlowExecRequestBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -141,7 +143,7 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_Success() { mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(flowStep, (*tidcommon.ServiceError)(nil)) - h := newFlowExecutionHandler(mockSvc) + h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) req := httptest.NewRequest(http.MethodPost, "/flow/execute", bytes.NewBufferString(testFlowExecRequestBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() @@ -150,6 +152,74 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_Success() { s.Equal(http.StatusOK, w.Code) } +// TestHandleFlowExecutionRequest_PropagatesInboundSSOCookie verifies the inbound per-flow SSO +// cookie is read off the request and propagated onto the context handed to the flow service. +func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_PropagatesInboundSSOCookie() { + t := s.T() + mockSvc := NewFlowExecServiceInterfaceMock(t) + + var gotInbound session.InboundHandle + var gotOK bool + mockSvc.EXPECT().Execute(mock.Anything, mock.Anything, mock.Anything, mock.Anything, + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Run(func(ctx context.Context, _ string, _ string, _ string, _ bool, _ string, + _ map[string]string, _ string, _ string) { + gotInbound, gotOK = session.InboundFrom(ctx) + }). + Return(&FlowStep{ExecutionID: "exec-1", Status: providers.FlowStatusIncomplete}, + (*tidcommon.ServiceError)(nil)) + + h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) + req := httptest.NewRequest(http.MethodPost, "/flow/execute", bytes.NewBufferString(testFlowExecRequestBody)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: session.CookieName("flow-1"), Value: "inbound-handle"}) + w := httptest.NewRecorder() + + h.HandleFlowExecutionRequest(w, req) + + s.Equal(http.StatusOK, w.Code) + s.Require().True(gotOK, "inbound SSO transport inputs must be propagated onto the service context") + s.Equal("inbound-handle", gotInbound.HandleFor("flow-1")) +} + +// TestHandleFlowExecutionRequest_WritesSSOHandleCookie verifies a minted handle is emitted as the +// per-flow cookie with the configured TTL and secure/http-only transport settings. +func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_WritesSSOHandleCookie() { + t := s.T() + mockSvc := NewFlowExecServiceInterfaceMock(t) + flowStep := &FlowStep{ + ExecutionID: "exec-1", + Status: providers.FlowStatusComplete, + SSOHandleOut: "minted-handle", + SSOFlowID: "flow-1", + } + mockSvc.EXPECT().Execute(mock.Anything, mock.Anything, mock.Anything, mock.Anything, + mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(flowStep, (*tidcommon.ServiceError)(nil)) + + // secure=true and a non-zero TTL so the emitted cookie carries the expected transport settings. + h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(true), time.Hour) + req := httptest.NewRequest(http.MethodPost, "/flow/execute", bytes.NewBufferString(testFlowExecRequestBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + h.HandleFlowExecutionRequest(w, req) + + s.Equal(http.StatusOK, w.Code) + var ssoCookie *http.Cookie + for _, ck := range w.Result().Cookies() { + if ck.Name == session.CookieName("flow-1") { + ssoCookie = ck + } + } + s.Require().NotNil(ssoCookie, "expected the per-flow SSO handle cookie to be set") + s.Equal("minted-handle", ssoCookie.Value) + s.Equal(int(time.Hour.Seconds()), ssoCookie.MaxAge) + s.Positive(ssoCookie.MaxAge, "cookie TTL must be non-zero") + s.True(ssoCookie.Secure) + s.True(ssoCookie.HttpOnly) +} + func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_StepWithError() { t := s.T() mockSvc := NewFlowExecServiceInterfaceMock(t) @@ -169,7 +239,7 @@ func (s *HandlerTestSuite) TestHandleFlowExecutionRequest_StepWithError() { mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(flowStep, (*tidcommon.ServiceError)(nil)) - h := newFlowExecutionHandler(mockSvc) + h := newFlowExecutionHandler(mockSvc, session.NewCookieTransport(false), 0) req := httptest.NewRequest(http.MethodPost, "/flow/execute", bytes.NewBufferString(testFlowExecRequestBody)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() diff --git a/backend/internal/flow/flowexec/init.go b/backend/internal/flow/flowexec/init.go index c97f667080..7684f9aea6 100644 --- a/backend/internal/flow/flowexec/init.go +++ b/backend/internal/flow/flowexec/init.go @@ -25,6 +25,7 @@ import ( "github.com/thunder-id/thunderid/internal/flow/executor" "github.com/thunder-id/thunderid/internal/flow/graphbuilder" "github.com/thunder-id/thunderid/internal/flow/interceptor" + "github.com/thunder-id/thunderid/internal/flow/session" kmprovider "github.com/thunder-id/thunderid/internal/system/kmprovider/common" "github.com/thunder-id/thunderid/internal/system/middleware" "github.com/thunder-id/thunderid/internal/system/transaction" @@ -52,7 +53,12 @@ func Initialize( flowExecService := newFlowExecService(flowProvider, flowStore, flowEngine, actorProvider, observabilitySvc, transactioner, cryptoSvc, graphBuilder, cfg) - handler := newFlowExecutionHandler(flowExecService) + // Mark the SSO cookie Secure unless the deployment is configured to serve over plain HTTP, and + // bound its lifetime to the session's configured absolute timeout (same fallback as the session + // executor's timeouts). + ssoTransport := session.NewCookieTransport(cfg.SecureCookies) + sessionTimeouts := session.NewTimeouts(cfg.Session.IdleTimeoutSeconds, cfg.Session.AbsoluteTimeoutSeconds) + handler := newFlowExecutionHandler(flowExecService, ssoTransport, sessionTimeouts.Absolute) registerRoutes(mux, handler) return flowExecService, nil diff --git a/backend/internal/flow/flowexec/model.go b/backend/internal/flow/flowexec/model.go index 4a118e66d9..ded0d4d740 100644 --- a/backend/internal/flow/flowexec/model.go +++ b/backend/internal/flow/flowexec/model.go @@ -87,6 +87,13 @@ type EngineContext struct { frameStack []*frame // sharedRuntimeData is a cross-frame key-value store available to executors that opt in. sharedRuntimeData map[string]string + // SSOHandleIn carries the inbound SSO handle for this request. It is transient: read from + // the transport at the start of execution and never persisted with the flow context. + SSOHandleIn string + // SSOFlowVersion is the current active version of this flow's definition, resolved via + // the flow management provider. Transient; used by the SSO-Check node to reject sessions + // established at an incompatible flow version. + SSOFlowVersion int } // mergeRuntimeData merges the given data into RuntimeData. @@ -215,6 +222,12 @@ type FlowStep struct { Data FlowData Assertion string Error *tidcommon.ServiceError + + // SSOHandleOut / SSOFlowID carry an SSO session handle minted during this step back to the + // transport layer (the handler), which sets it as a per-flow cookie. They are not part of + // the JSON response body. + SSOHandleOut string + SSOFlowID string } // FlowData holds the data returned by a flow execution step diff --git a/backend/internal/flow/flowexec/service.go b/backend/internal/flow/flowexec/service.go index 9700e7d0b1..9d2960183e 100644 --- a/backend/internal/flow/flowexec/service.go +++ b/backend/internal/flow/flowexec/service.go @@ -32,7 +32,9 @@ import ( "github.com/thunder-id/thunderid/internal/flow/common" flowconfig "github.com/thunder-id/thunderid/internal/flow/config" "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/executor" "github.com/thunder-id/thunderid/internal/flow/graphbuilder" + "github.com/thunder-id/thunderid/internal/flow/session" sysContext "github.com/thunder-id/thunderid/internal/system/context" "github.com/thunder-id/thunderid/internal/system/cryptolib" kmprovider "github.com/thunder-id/thunderid/internal/system/kmprovider/common" @@ -128,6 +130,17 @@ func (s *flowExecService) Execute(ctx context.Context, // Set trace ID to engine context (request context is already set during context loading) engineCtx.TraceID = traceID + // Resolve the inbound SSO handle for this flow from the request-scoped transport inputs. + applyInboundSSO(engineCtx, ctx) + // Resolve the active flow version whenever the flow establishes or consults an SSO session. + // Both paths need it: the save path (fresh login, which carries no inbound handle) stamps the + // version onto the new session, and the check path compares against it. Gating this on an + // inbound handle would save sessions at version 0 and then fail the version check on the next + // login. Flows that use no SSO session skip the lookup. + if flowUsesSSOSession(engineCtx.Graph) { + engineCtx.SSOFlowVersion = s.resolveActiveFlowVersion(ctx, engineCtx, logger) + } + flowStep, flowErr := s.flowEngine.Execute(engineCtx) if flowErr != nil { @@ -168,6 +181,57 @@ func (s *flowExecService) Execute(ctx context.Context, return &flowStep, nil } +// applyInboundSSO selects the SSO handle carried for this flow from the request-scoped +// transport inputs and stashes it on the engine context for the SSO-Check node to consume. +// It is a no-op when no inbound transport is present. +func applyInboundSSO(engineCtx *EngineContext, ctx context.Context) { + if engineCtx == nil || engineCtx.Graph == nil { + return + } + inbound, ok := session.InboundFrom(ctx) + if !ok { + return + } + engineCtx.SSOHandleIn = inbound.HandleFor(engineCtx.Graph.GetID()) +} + +// flowUsesSSOSession reports whether the flow graph contains a node that establishes or +// consults an SSO session (the Session or SSO-Check executors). It gates the active-flow-version +// lookup so only SSO-capable flows pay for it. +func flowUsesSSOSession(graph core.GraphInterface) bool { + if graph == nil { + return false + } + for _, node := range graph.GetNodes() { + execNode, ok := node.(core.ExecutorBackedNodeInterface) + if !ok { + continue + } + name := execNode.GetExecutorName() + if name == executor.ExecutorNameSession || name == executor.ExecutorNameSSOCheck { + return true + } + } + return false +} + +// resolveActiveFlowVersion returns the current active version of the flow, or 0 when it +// cannot be determined. A 0 (unknown) version makes the SSO-Check node treat any saved +// session as version-incompatible, i.e. it falls back to full authentication. +func (s *flowExecService) resolveActiveFlowVersion(ctx context.Context, engineCtx *EngineContext, + logger *log.Logger) int { + if engineCtx.Graph == nil { + return 0 + } + def, svcErr := s.flowProvider.GetFlow(ctx, engineCtx.Graph.GetID()) + if svcErr != nil || def == nil { + logger.Debug(ctx, "Could not resolve active flow version for SSO check", + log.String("flowID", engineCtx.Graph.GetID())) + return 0 + } + return def.ActiveVersion +} + // initContext initializes a new flow context with the given details. func (s *flowExecService) loadNewContext(ctx context.Context, appID, flowTypeStr string, verbose bool, action string, inputs map[string]string, flowSecret string, logger *log.Logger) ( diff --git a/backend/internal/flow/flowexec/service_sso_test.go b/backend/internal/flow/flowexec/service_sso_test.go new file mode 100644 index 0000000000..c3c28bb164 --- /dev/null +++ b/backend/internal/flow/flowexec/service_sso_test.go @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package flowexec + +import ( + "context" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/flow/common" + "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/executor" + "github.com/thunder-id/thunderid/internal/flow/session" + "github.com/thunder-id/thunderid/internal/system/cache" + "github.com/thunder-id/thunderid/internal/system/config" + "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +const testFlowID = "auth-graph-1" + +type ServiceSSOTestSuite struct { + suite.Suite +} + +func TestServiceSSOTestSuite(t *testing.T) { + suite.Run(t, new(ServiceSSOTestSuite)) +} + +func (s *ServiceSSOTestSuite) SetupTest() { + s.Require().NoError(config.InitializeServerRuntime(s.T().TempDir(), &config.Config{})) +} + +func (s *ServiceSSOTestSuite) TearDownTest() { + config.ResetServerRuntime() +} + +func (s *ServiceSSOTestSuite) newTestGraph() core.GraphInterface { + flowFactory, _ := core.Initialize(cache.Initialize(config.GetServerRuntime().Config.Cache, "test-deployment")) + return flowFactory.CreateGraph(testFlowID, providers.FlowTypeAuthentication, 1) +} + +// newGraphWithExecutor builds a single-node graph whose node is backed by the given executor. +func (s *ServiceSSOTestSuite) newGraphWithExecutor(executorName string) core.GraphInterface { + flowFactory, _ := core.Initialize(cache.Initialize(config.GetServerRuntime().Config.Cache, "test-deployment")) + graph := flowFactory.CreateGraph(testFlowID, providers.FlowTypeAuthentication, 1) + node, err := flowFactory.CreateNode("n1", string(common.NodeTypeTaskExecution), nil, false, false) + s.Require().NoError(err) + node.(core.ExecutorBackedNodeInterface).SetExecutorName(executorName) + s.Require().NoError(graph.AddNode(node)) + return graph +} + +func (s *ServiceSSOTestSuite) TestApplyInboundSSO_SelectsHandleForFlow() { + engineCtx := &EngineContext{Graph: s.newTestGraph()} + + ih := session.InboundHandle{ + Cookies: map[string]string{session.CookieName(testFlowID): "handle-1"}, + } + ctx := session.WithInbound(context.Background(), ih) + + applyInboundSSO(engineCtx, ctx) + + s.Equal("handle-1", engineCtx.SSOHandleIn) +} + +func (s *ServiceSSOTestSuite) TestApplyInboundSSO_NoInbound() { + engineCtx := &EngineContext{Graph: s.newTestGraph()} + + applyInboundSSO(engineCtx, context.Background()) + + s.Empty(engineCtx.SSOHandleIn) +} + +func (s *ServiceSSOTestSuite) TestApplyInboundSSO_NilGraph() { + engineCtx := &EngineContext{} + ctx := session.WithInbound(context.Background(), + session.InboundHandle{Cookies: map[string]string{}}) + + applyInboundSSO(engineCtx, ctx) + + s.Empty(engineCtx.SSOHandleIn) +} + +func (s *ServiceSSOTestSuite) TestResolveActiveFlowVersion_FromProvider() { + provider := NewFlowProviderMock(s.T()) + provider.EXPECT().GetFlow(mock.Anything, testFlowID). + Return(&providers.CompleteFlowDefinition{ID: testFlowID, ActiveVersion: 5}, nil) + svc := &flowExecService{flowProvider: provider} + engineCtx := &EngineContext{Graph: s.newTestGraph()} + + version := svc.resolveActiveFlowVersion(context.Background(), engineCtx, log.GetLogger()) + + s.Equal(5, version) +} + +func (s *ServiceSSOTestSuite) TestResolveActiveFlowVersion_NilGraph() { + // A nil graph short-circuits before the provider is consulted, so no GetFlow call is expected. + svc := &flowExecService{flowProvider: NewFlowProviderMock(s.T())} + + version := svc.resolveActiveFlowVersion(context.Background(), &EngineContext{}, log.GetLogger()) + + s.Equal(0, version) +} + +// TestFlowUsesSSOSession covers the version-lookup gate: a flow that establishes (Session) or consults +// (SSO-Check) a session must have its active version resolved on every path — including the +// fresh-login save path, which carries no inbound handle. Gating the version lookup on an inbound +// handle would persist sessions at version 0 and fail the version check on the next login. +func (s *ServiceSSOTestSuite) TestFlowUsesSSOSession() { + s.True(flowUsesSSOSession(s.newGraphWithExecutor(executor.ExecutorNameSession))) + s.True(flowUsesSSOSession(s.newGraphWithExecutor(executor.ExecutorNameSSOCheck))) + s.False(flowUsesSSOSession(s.newGraphWithExecutor(executor.ExecutorNameCredentialsAuth))) + s.False(flowUsesSSOSession(nil)) +} diff --git a/backend/internal/flow/graphbuilder/graph_builder_test.go b/backend/internal/flow/graphbuilder/graph_builder_test.go index de617153c2..70cc778f92 100644 --- a/backend/internal/flow/graphbuilder/graph_builder_test.go +++ b/backend/internal/flow/graphbuilder/graph_builder_test.go @@ -20,7 +20,9 @@ package graphbuilder import ( "context" + "encoding/json" "errors" + "os" "testing" "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" @@ -30,7 +32,9 @@ import ( "github.com/thunder-id/thunderid/internal/flow/common" "github.com/thunder-id/thunderid/internal/flow/core" + "github.com/thunder-id/thunderid/internal/flow/executor" "github.com/thunder-id/thunderid/internal/flow/interceptor" + "github.com/thunder-id/thunderid/internal/system/cache" "github.com/thunder-id/thunderid/internal/system/config" "github.com/thunder-id/thunderid/internal/system/log" engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" @@ -1956,3 +1960,49 @@ func (s *GraphBuilderTestSuite) TestConfigureNodePrompts_InvalidRegexFailsBuild( s.Contains(err.Error(), "password") s.Contains(err.Error(), "invalid validation regex") } + +// TestSSOFlowDefinitionBuilds validates that the SSO sample flow parses, references only +// registered executors, and builds into a graph with all expected nodes. It exercises the +// real executor registry so a typo'd executor name or dangling node reference fails here. +func (s *GraphBuilderTestSuite) TestSSOFlowDefinitionBuilds() { + raw, err := os.ReadFile("testdata/sso_flow.json") + s.Require().NoError(err) + + var def providers.CompleteFlowDefinition + s.Require().NoError(json.Unmarshal(raw, &def)) + def.ID = "auth-sso-flow-test" + + flowFactory, graphCache := core.Initialize( + cache.Initialize(config.GetServerRuntime().Config.Cache, "test-deployment")) + // Register only the executors this flow uses; their constructors tolerate nil services, + // whereas some others dereference dependencies at construction time. + registry, err := executor.Initialize( + executor.ExecutorDependencies{FlowFactory: flowFactory}, + engineconfig.FlowConfig{Executors: []string{ + executor.ExecutorNameSSOCheck, + executor.ExecutorNameSession, + executor.ExecutorNameCredentialsAuth, + executor.ExecutorNameAuthorization, + executor.ExecutorNameAuthAssert, + }}) + s.Require().NoError(err) + + interceptorRegistry, err := interceptor.Initialize( + interceptor.InterceptorDependencies{FlowFactory: flowFactory}, + engineconfig.FlowConfig{}) + s.Require().NoError(err) + + builder := Initialize(flowFactory, registry, interceptorRegistry, graphCache) + graph, svcErr := builder.GetGraph(context.Background(), &def) + + s.Require().Nil(svcErr) + s.Require().NotNil(graph) + + for _, nodeID := range []string{ + "start", "sso_check", "prompt_credentials", "basic_auth", + "session", "authorization_check", "auth_assert", "end", + } { + _, ok := graph.GetNode(nodeID) + s.Require().True(ok, "expected node %q in built graph", nodeID) + } +} diff --git a/backend/internal/flow/graphbuilder/testdata/sso_flow.json b/backend/internal/flow/graphbuilder/testdata/sso_flow.json new file mode 100644 index 0000000000..3b40cbcf67 --- /dev/null +++ b/backend/internal/flow/graphbuilder/testdata/sso_flow.json @@ -0,0 +1,127 @@ +{ + "name": "Default SSO Authentication Flow", + "handle": "default-sso-flow", + "flowType": "AUTHENTICATION", + "nodes": [ + { + "id": "start", + "type": "START", + "onSuccess": "sso_check" + }, + { + "id": "sso_check", + "type": "TASK_EXECUTION", + "executor": { + "name": "SSOCheckExecutor" + }, + "properties": { + "checkpointRef": "session" + }, + "onSuccess": "session", + "onFailure": "prompt_credentials" + }, + { + "id": "basic_auth", + "type": "TASK_EXECUTION", + "executor": { + "name": "CredentialsAuthExecutor" + }, + "onSuccess": "session", + "onIncomplete": "prompt_credentials" + }, + { + "id": "prompt_credentials", + "type": "PROMPT", + "meta": { + "components": [ + { + "align": "center", + "type": "TEXT", + "id": "text_001", + "label": "{{ t(signin:forms.credentials.title) }}", + "variant": "HEADING_1" + }, + { + "type": "BLOCK", + "id": "block_001", + "components": [ + { + "id": "input_001", + "ref": "username", + "type": "TEXT_INPUT", + "label": "{{ t(signin:forms.credentials.fields.username.label) }}", + "required": true, + "placeholder": "{{ t(signin:forms.credentials.fields.username.placeholder) }}" + }, + { + "id": "input_002", + "ref": "password", + "type": "PASSWORD_INPUT", + "label": "{{ t(signin:forms.credentials.fields.password.label) }}", + "required": true, + "placeholder": "{{ t(signin:forms.credentials.fields.password.placeholder) }}" + }, + { + "type": "ACTION", + "id": "action_001", + "label": "{{ t(signin:forms.credentials.actions.submit.label) }}", + "variant": "PRIMARY", + "eventType": "SUBMIT" + } + ] + } + ] + }, + "prompts": [ + { + "inputs": [ + { + "ref": "input_001", + "identifier": "username", + "type": "TEXT_INPUT", + "required": true + }, + { + "ref": "input_002", + "identifier": "password", + "type": "PASSWORD_INPUT", + "required": true + } + ], + "action": { + "ref": "action_001", + "nextNode": "basic_auth" + } + } + ] + }, + { + "id": "session", + "type": "TASK_EXECUTION", + "executor": { + "name": "SessionExecutor" + }, + "onSuccess": "authorization_check" + }, + { + "id": "authorization_check", + "type": "TASK_EXECUTION", + "executor": { + "name": "AuthorizationExecutor" + }, + "onSuccess": "auth_assert" + }, + { + "id": "auth_assert", + "type": "TASK_EXECUTION", + "executor": { + "name": "AuthAssertExecutor" + }, + "onSuccess": "end" + }, + { + "id": "end", + "type": "END" + } + ] +} diff --git a/backend/internal/flow/session/HandleTransport_mock_test.go b/backend/internal/flow/session/HandleTransport_mock_test.go new file mode 100644 index 0000000000..18068d46d5 --- /dev/null +++ b/backend/internal/flow/session/HandleTransport_mock_test.go @@ -0,0 +1,194 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package session + +import ( + "net/http" + "time" + + mock "github.com/stretchr/testify/mock" +) + +// NewHandleTransportMock creates a new instance of HandleTransportMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewHandleTransportMock(t interface { + mock.TestingT + Cleanup(func()) +}) *HandleTransportMock { + mock := &HandleTransportMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// HandleTransportMock is an autogenerated mock type for the HandleTransport type +type HandleTransportMock struct { + mock.Mock +} + +type HandleTransportMock_Expecter struct { + mock *mock.Mock +} + +func (_m *HandleTransportMock) EXPECT() *HandleTransportMock_Expecter { + return &HandleTransportMock_Expecter{mock: &_m.Mock} +} + +// Clear provides a mock function for the type HandleTransportMock +func (_mock *HandleTransportMock) Clear(w http.ResponseWriter, cookieName string) { + _mock.Called(w, cookieName) + return +} + +// HandleTransportMock_Clear_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clear' +type HandleTransportMock_Clear_Call struct { + *mock.Call +} + +// Clear is a helper method to define mock.On call +// - w http.ResponseWriter +// - cookieName string +func (_e *HandleTransportMock_Expecter) Clear(w interface{}, cookieName interface{}) *HandleTransportMock_Clear_Call { + return &HandleTransportMock_Clear_Call{Call: _e.mock.On("Clear", w, cookieName)} +} + +func (_c *HandleTransportMock_Clear_Call) Run(run func(w http.ResponseWriter, cookieName string)) *HandleTransportMock_Clear_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 http.ResponseWriter + if args[0] != nil { + arg0 = args[0].(http.ResponseWriter) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *HandleTransportMock_Clear_Call) Return() *HandleTransportMock_Clear_Call { + _c.Call.Return() + return _c +} + +func (_c *HandleTransportMock_Clear_Call) RunAndReturn(run func(w http.ResponseWriter, cookieName string)) *HandleTransportMock_Clear_Call { + _c.Run(run) + return _c +} + +// Read provides a mock function for the type HandleTransportMock +func (_mock *HandleTransportMock) Read(r *http.Request) InboundHandle { + ret := _mock.Called(r) + + if len(ret) == 0 { + panic("no return value specified for Read") + } + + var r0 InboundHandle + if returnFunc, ok := ret.Get(0).(func(*http.Request) InboundHandle); ok { + r0 = returnFunc(r) + } else { + r0 = ret.Get(0).(InboundHandle) + } + return r0 +} + +// HandleTransportMock_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' +type HandleTransportMock_Read_Call struct { + *mock.Call +} + +// Read is a helper method to define mock.On call +// - r *http.Request +func (_e *HandleTransportMock_Expecter) Read(r interface{}) *HandleTransportMock_Read_Call { + return &HandleTransportMock_Read_Call{Call: _e.mock.On("Read", r)} +} + +func (_c *HandleTransportMock_Read_Call) Run(run func(r *http.Request)) *HandleTransportMock_Read_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *http.Request + if args[0] != nil { + arg0 = args[0].(*http.Request) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *HandleTransportMock_Read_Call) Return(inboundHandle InboundHandle) *HandleTransportMock_Read_Call { + _c.Call.Return(inboundHandle) + return _c +} + +func (_c *HandleTransportMock_Read_Call) RunAndReturn(run func(r *http.Request) InboundHandle) *HandleTransportMock_Read_Call { + _c.Call.Return(run) + return _c +} + +// Write provides a mock function for the type HandleTransportMock +func (_mock *HandleTransportMock) Write(w http.ResponseWriter, cookieName string, handle string, ttl time.Duration) { + _mock.Called(w, cookieName, handle, ttl) + return +} + +// HandleTransportMock_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' +type HandleTransportMock_Write_Call struct { + *mock.Call +} + +// Write is a helper method to define mock.On call +// - w http.ResponseWriter +// - cookieName string +// - handle string +// - ttl time.Duration +func (_e *HandleTransportMock_Expecter) Write(w interface{}, cookieName interface{}, handle interface{}, ttl interface{}) *HandleTransportMock_Write_Call { + return &HandleTransportMock_Write_Call{Call: _e.mock.On("Write", w, cookieName, handle, ttl)} +} + +func (_c *HandleTransportMock_Write_Call) Run(run func(w http.ResponseWriter, cookieName string, handle string, ttl time.Duration)) *HandleTransportMock_Write_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 http.ResponseWriter + if args[0] != nil { + arg0 = args[0].(http.ResponseWriter) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 time.Duration + if args[3] != nil { + arg3 = args[3].(time.Duration) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *HandleTransportMock_Write_Call) Return() *HandleTransportMock_Write_Call { + _c.Call.Return() + return _c +} + +func (_c *HandleTransportMock_Write_Call) RunAndReturn(run func(w http.ResponseWriter, cookieName string, handle string, ttl time.Duration)) *HandleTransportMock_Write_Call { + _c.Run(run) + return _c +} diff --git a/backend/internal/flow/session/Resolver_mock_test.go b/backend/internal/flow/session/Resolver_mock_test.go new file mode 100644 index 0000000000..3eb68b234f --- /dev/null +++ b/backend/internal/flow/session/Resolver_mock_test.go @@ -0,0 +1,113 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package session + +import ( + "context" + "time" + + mock "github.com/stretchr/testify/mock" +) + +// NewResolverMock creates a new instance of ResolverMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewResolverMock(t interface { + mock.TestingT + Cleanup(func()) +}) *ResolverMock { + mock := &ResolverMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// ResolverMock is an autogenerated mock type for the Resolver type +type ResolverMock struct { + mock.Mock +} + +type ResolverMock_Expecter struct { + mock *mock.Mock +} + +func (_m *ResolverMock) EXPECT() *ResolverMock_Expecter { + return &ResolverMock_Expecter{mock: &_m.Mock} +} + +// Resolve provides a mock function for the type ResolverMock +func (_mock *ResolverMock) Resolve(ctx context.Context, handleID string, now time.Time) (*Session, error) { + ret := _mock.Called(ctx, handleID, now) + + if len(ret) == 0 { + panic("no return value specified for Resolve") + } + + var r0 *Session + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (*Session, error)); ok { + return returnFunc(ctx, handleID, now) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) *Session); ok { + r0 = returnFunc(ctx, handleID, now) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok { + r1 = returnFunc(ctx, handleID, now) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ResolverMock_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' +type ResolverMock_Resolve_Call struct { + *mock.Call +} + +// Resolve is a helper method to define mock.On call +// - ctx context.Context +// - handleID string +// - now time.Time +func (_e *ResolverMock_Expecter) Resolve(ctx interface{}, handleID interface{}, now interface{}) *ResolverMock_Resolve_Call { + return &ResolverMock_Resolve_Call{Call: _e.mock.On("Resolve", ctx, handleID, now)} +} + +func (_c *ResolverMock_Resolve_Call) Run(run func(ctx context.Context, handleID string, now time.Time)) *ResolverMock_Resolve_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 time.Time + if args[2] != nil { + arg2 = args[2].(time.Time) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ResolverMock_Resolve_Call) Return(session *Session, err error) *ResolverMock_Resolve_Call { + _c.Call.Return(session, err) + return _c +} + +func (_c *ResolverMock_Resolve_Call) RunAndReturn(run func(ctx context.Context, handleID string, now time.Time) (*Session, error)) *ResolverMock_Resolve_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/flow/session/Service_mock_test.go b/backend/internal/flow/session/Service_mock_test.go new file mode 100644 index 0000000000..c233697202 --- /dev/null +++ b/backend/internal/flow/session/Service_mock_test.go @@ -0,0 +1,351 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package session + +import ( + "context" + "time" + + mock "github.com/stretchr/testify/mock" +) + +// NewServiceMock creates a new instance of ServiceMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewServiceMock(t interface { + mock.TestingT + Cleanup(func()) +}) *ServiceMock { + mock := &ServiceMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// ServiceMock is an autogenerated mock type for the Service type +type ServiceMock struct { + mock.Mock +} + +type ServiceMock_Expecter struct { + mock *mock.Mock +} + +func (_m *ServiceMock) EXPECT() *ServiceMock_Expecter { + return &ServiceMock_Expecter{mock: &_m.Mock} +} + +// HasCheckpoint provides a mock function for the type ServiceMock +func (_mock *ServiceMock) HasCheckpoint(ctx context.Context, sessionID string, checkpoint string) (bool, error) { + ret := _mock.Called(ctx, sessionID, checkpoint) + + if len(ret) == 0 { + panic("no return value specified for HasCheckpoint") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (bool, error)); ok { + return returnFunc(ctx, sessionID, checkpoint) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { + r0 = returnFunc(ctx, sessionID, checkpoint) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, sessionID, checkpoint) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ServiceMock_HasCheckpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasCheckpoint' +type ServiceMock_HasCheckpoint_Call struct { + *mock.Call +} + +// HasCheckpoint is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +// - checkpoint string +func (_e *ServiceMock_Expecter) HasCheckpoint(ctx interface{}, sessionID interface{}, checkpoint interface{}) *ServiceMock_HasCheckpoint_Call { + return &ServiceMock_HasCheckpoint_Call{Call: _e.mock.On("HasCheckpoint", ctx, sessionID, checkpoint)} +} + +func (_c *ServiceMock_HasCheckpoint_Call) Run(run func(ctx context.Context, sessionID string, checkpoint string)) *ServiceMock_HasCheckpoint_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ServiceMock_HasCheckpoint_Call) Return(b bool, err error) *ServiceMock_HasCheckpoint_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *ServiceMock_HasCheckpoint_Call) RunAndReturn(run func(ctx context.Context, sessionID string, checkpoint string) (bool, error)) *ServiceMock_HasCheckpoint_Call { + _c.Call.Return(run) + return _c +} + +// LoadCheckpoint provides a mock function for the type ServiceMock +func (_mock *ServiceMock) LoadCheckpoint(ctx context.Context, handle string, checkpoint string, appID string) (*Session, *SessionContext, error) { + ret := _mock.Called(ctx, handle, checkpoint, appID) + + if len(ret) == 0 { + panic("no return value specified for LoadCheckpoint") + } + + var r0 *Session + var r1 *SessionContext + var r2 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*Session, *SessionContext, error)); ok { + return returnFunc(ctx, handle, checkpoint, appID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *Session); ok { + r0 = returnFunc(ctx, handle, checkpoint, appID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) *SessionContext); ok { + r1 = returnFunc(ctx, handle, checkpoint, appID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*SessionContext) + } + } + if returnFunc, ok := ret.Get(2).(func(context.Context, string, string, string) error); ok { + r2 = returnFunc(ctx, handle, checkpoint, appID) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// ServiceMock_LoadCheckpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadCheckpoint' +type ServiceMock_LoadCheckpoint_Call struct { + *mock.Call +} + +// LoadCheckpoint is a helper method to define mock.On call +// - ctx context.Context +// - handle string +// - checkpoint string +// - appID string +func (_e *ServiceMock_Expecter) LoadCheckpoint(ctx interface{}, handle interface{}, checkpoint interface{}, appID interface{}) *ServiceMock_LoadCheckpoint_Call { + return &ServiceMock_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", ctx, handle, checkpoint, appID)} +} + +func (_c *ServiceMock_LoadCheckpoint_Call) Run(run func(ctx context.Context, handle string, checkpoint string, appID string)) *ServiceMock_LoadCheckpoint_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *ServiceMock_LoadCheckpoint_Call) Return(session *Session, sessionContext *SessionContext, err error) *ServiceMock_LoadCheckpoint_Call { + _c.Call.Return(session, sessionContext, err) + return _c +} + +func (_c *ServiceMock_LoadCheckpoint_Call) RunAndReturn(run func(ctx context.Context, handle string, checkpoint string, appID string) (*Session, *SessionContext, error)) *ServiceMock_LoadCheckpoint_Call { + _c.Call.Return(run) + return _c +} + +// Resolve provides a mock function for the type ServiceMock +func (_mock *ServiceMock) Resolve(ctx context.Context, handle string, flowID string, flowVersion int, now time.Time) (*Session, error) { + ret := _mock.Called(ctx, handle, flowID, flowVersion, now) + + if len(ret) == 0 { + panic("no return value specified for Resolve") + } + + var r0 *Session + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int, time.Time) (*Session, error)); ok { + return returnFunc(ctx, handle, flowID, flowVersion, now) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int, time.Time) *Session); ok { + r0 = returnFunc(ctx, handle, flowID, flowVersion, now) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, int, time.Time) error); ok { + r1 = returnFunc(ctx, handle, flowID, flowVersion, now) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ServiceMock_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' +type ServiceMock_Resolve_Call struct { + *mock.Call +} + +// Resolve is a helper method to define mock.On call +// - ctx context.Context +// - handle string +// - flowID string +// - flowVersion int +// - now time.Time +func (_e *ServiceMock_Expecter) Resolve(ctx interface{}, handle interface{}, flowID interface{}, flowVersion interface{}, now interface{}) *ServiceMock_Resolve_Call { + return &ServiceMock_Resolve_Call{Call: _e.mock.On("Resolve", ctx, handle, flowID, flowVersion, now)} +} + +func (_c *ServiceMock_Resolve_Call) Run(run func(ctx context.Context, handle string, flowID string, flowVersion int, now time.Time)) *ServiceMock_Resolve_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 int + if args[3] != nil { + arg3 = args[3].(int) + } + var arg4 time.Time + if args[4] != nil { + arg4 = args[4].(time.Time) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *ServiceMock_Resolve_Call) Return(session *Session, err error) *ServiceMock_Resolve_Call { + _c.Call.Return(session, err) + return _c +} + +func (_c *ServiceMock_Resolve_Call) RunAndReturn(run func(ctx context.Context, handle string, flowID string, flowVersion int, now time.Time) (*Session, error)) *ServiceMock_Resolve_Call { + _c.Call.Return(run) + return _c +} + +// SaveCheckpoint provides a mock function for the type ServiceMock +func (_mock *ServiceMock) SaveCheckpoint(ctx context.Context, in SaveCheckpointInput) (SaveCheckpointResult, error) { + ret := _mock.Called(ctx, in) + + if len(ret) == 0 { + panic("no return value specified for SaveCheckpoint") + } + + var r0 SaveCheckpointResult + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, SaveCheckpointInput) (SaveCheckpointResult, error)); ok { + return returnFunc(ctx, in) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, SaveCheckpointInput) SaveCheckpointResult); ok { + r0 = returnFunc(ctx, in) + } else { + r0 = ret.Get(0).(SaveCheckpointResult) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, SaveCheckpointInput) error); ok { + r1 = returnFunc(ctx, in) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ServiceMock_SaveCheckpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveCheckpoint' +type ServiceMock_SaveCheckpoint_Call struct { + *mock.Call +} + +// SaveCheckpoint is a helper method to define mock.On call +// - ctx context.Context +// - in SaveCheckpointInput +func (_e *ServiceMock_Expecter) SaveCheckpoint(ctx interface{}, in interface{}) *ServiceMock_SaveCheckpoint_Call { + return &ServiceMock_SaveCheckpoint_Call{Call: _e.mock.On("SaveCheckpoint", ctx, in)} +} + +func (_c *ServiceMock_SaveCheckpoint_Call) Run(run func(ctx context.Context, in SaveCheckpointInput)) *ServiceMock_SaveCheckpoint_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 SaveCheckpointInput + if args[1] != nil { + arg1 = args[1].(SaveCheckpointInput) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *ServiceMock_SaveCheckpoint_Call) Return(saveCheckpointResult SaveCheckpointResult, err error) *ServiceMock_SaveCheckpoint_Call { + _c.Call.Return(saveCheckpointResult, err) + return _c +} + +func (_c *ServiceMock_SaveCheckpoint_Call) RunAndReturn(run func(ctx context.Context, in SaveCheckpointInput) (SaveCheckpointResult, error)) *ServiceMock_SaveCheckpoint_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/flow/session/config.go b/backend/internal/flow/session/config.go new file mode 100644 index 0000000000..dc298ad3b2 --- /dev/null +++ b/backend/internal/flow/session/config.go @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "encoding/json" + "fmt" +) + +// Config is the value of the server-config "session" section: the SSO session lifetime +// configuration. Durations are in seconds; a zero or omitted value falls back to the built-in +// default (see NewTimeouts). +type Config struct { + IdleTimeoutSeconds int64 `json:"idleTimeoutSeconds" yaml:"idleTimeoutSeconds"` + AbsoluteTimeoutSeconds int64 `json:"absoluteTimeoutSeconds" yaml:"absoluteTimeoutSeconds"` +} + +// Validate ensures the configured session timeouts are coherent. Unset (zero) values are allowed +// and fall back to defaults, so only set values are checked. +func (c Config) Validate() error { + if c.IdleTimeoutSeconds < 0 { + return fmt.Errorf("session.idleTimeoutSeconds must be greater than or equal to 0") + } + if c.AbsoluteTimeoutSeconds < 0 { + return fmt.Errorf("session.absoluteTimeoutSeconds must be greater than or equal to 0") + } + if c.IdleTimeoutSeconds > 0 && c.AbsoluteTimeoutSeconds > 0 && + c.IdleTimeoutSeconds > c.AbsoluteTimeoutSeconds { + return fmt.Errorf("session.idleTimeoutSeconds must not exceed absoluteTimeoutSeconds") + } + return nil +} + +// ConfigHandler decodes, validates, and merges the "session" server-config section. It implements +// the serverconfig section-handler contract structurally so the section can be registered at the +// composition root without this package depending on the serverconfig package. +type ConfigHandler struct{} + +// Decode parses a raw JSON session value into Config. Empty input yields the zero Config, which +// resolves to the built-in default timeouts. +func (ConfigHandler) Decode(raw json.RawMessage) (any, error) { + if len(raw) == 0 { + return Config{}, nil + } + var cfg Config + if err := json.Unmarshal(raw, &cfg); err != nil { + return nil, err + } + return cfg, nil +} + +// Validate checks that the incoming value is a coherent session config. +func (ConfigHandler) Validate(incoming, _, _ any) error { + cfg, _ := incoming.(Config) + return cfg.Validate() +} + +// Merge overlays the writable (db) layer onto the read-only (declarative) layer: a positive writable +// value wins for its field, otherwise the read-only value stands. +func (ConfigHandler) Merge(readOnly, writable any) any { + merged, _ := readOnly.(Config) + wr, _ := writable.(Config) + if wr.IdleTimeoutSeconds > 0 { + merged.IdleTimeoutSeconds = wr.IdleTimeoutSeconds + } + if wr.AbsoluteTimeoutSeconds > 0 { + merged.AbsoluteTimeoutSeconds = wr.AbsoluteTimeoutSeconds + } + return merged +} diff --git a/backend/internal/flow/session/config_test.go b/backend/internal/flow/session/config_test.go new file mode 100644 index 0000000000..c166e975c8 --- /dev/null +++ b/backend/internal/flow/session/config_test.go @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/suite" +) + +type ConfigTestSuite struct { + suite.Suite +} + +func TestConfigTestSuite(t *testing.T) { + suite.Run(t, new(ConfigTestSuite)) +} + +func (s *ConfigTestSuite) TestValidate_UnsetUsesDefaults() { + s.Require().NoError(Config{}.Validate()) +} + +func (s *ConfigTestSuite) TestValidate_PositiveValues() { + s.Require().NoError(Config{IdleTimeoutSeconds: 1800, AbsoluteTimeoutSeconds: 28800}.Validate()) +} + +func (s *ConfigTestSuite) TestValidate_NegativeRejected() { + s.Require().Error(Config{IdleTimeoutSeconds: -1}.Validate()) + s.Require().Error(Config{AbsoluteTimeoutSeconds: -1}.Validate()) +} + +func (s *ConfigTestSuite) TestValidate_IdleExceedsAbsolute() { + s.Require().Error(Config{IdleTimeoutSeconds: 28801, AbsoluteTimeoutSeconds: 28800}.Validate()) +} + +func (s *ConfigTestSuite) TestHandler_DecodeEmptyIsZero() { + got, err := ConfigHandler{}.Decode(nil) + s.Require().NoError(err) + s.Equal(Config{}, got) +} + +func (s *ConfigTestSuite) TestHandler_DecodeJSON() { + got, err := ConfigHandler{}.Decode(json.RawMessage(`{"idleTimeoutSeconds":900,"absoluteTimeoutSeconds":3600}`)) + s.Require().NoError(err) + s.Equal(Config{IdleTimeoutSeconds: 900, AbsoluteTimeoutSeconds: 3600}, got) +} + +func (s *ConfigTestSuite) TestHandler_ValidateRejectsIncoherent() { + s.Require().Error(ConfigHandler{}.Validate(Config{IdleTimeoutSeconds: -5}, nil, nil)) +} + +func (s *ConfigTestSuite) TestHandler_MergeWritableWins() { + readOnly := Config{IdleTimeoutSeconds: 1800, AbsoluteTimeoutSeconds: 28800} + writable := Config{IdleTimeoutSeconds: 600} + merged := ConfigHandler{}.Merge(readOnly, writable).(Config) + // A positive writable field overrides read-only; an unset writable field keeps read-only. + s.Equal(int64(600), merged.IdleTimeoutSeconds) + s.Equal(int64(28800), merged.AbsoluteTimeoutSeconds) +} diff --git a/backend/internal/flow/session/error_constants.go b/backend/internal/flow/session/error_constants.go new file mode 100644 index 0000000000..f9583fcbe8 --- /dev/null +++ b/backend/internal/flow/session/error_constants.go @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import "errors" + +var ( + // errVersionConflict is returned by Update when the optimistic-lock version no longer + // matches (the row was updated concurrently or no longer exists). + errVersionConflict = errors.New("session version conflict") + + // errSessionContextTooLarge is returned when a serialized session context exceeds + // MaxSessionContextBytes. The bounded snapshot keeps the sibling row small. + errSessionContextTooLarge = errors.New("session context exceeds maximum size") +) diff --git a/backend/internal/flow/session/init.go b/backend/internal/flow/session/init.go new file mode 100644 index 0000000000..265f7c0832 --- /dev/null +++ b/backend/internal/flow/session/init.go @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "fmt" + + "github.com/thunder-id/thunderid/internal/system/database/provider" + "github.com/thunder-id/thunderid/internal/system/log" +) + +// Initialize builds the SSO session Service. Store construction stays inside this package: callers +// receive only the Service and never hold a store. Timeouts fall back per field to the built-in +// defaults so an unset (zero) value never makes sessions expire immediately. +func Initialize(dbProvider provider.DBProviderInterface, deploymentID string, + timeouts Timeouts) (Service, error) { + transactioner, err := dbProvider.GetOperationDBTransactioner() + if err != nil { + return nil, fmt.Errorf("failed to get runtime DB transactioner for the SSO session service: %w", err) + } + + def := DefaultTimeouts() + if timeouts.Idle <= 0 { + timeouts.Idle = def.Idle + } + if timeouts.Absolute <= 0 { + timeouts.Absolute = def.Absolute + } + + store := newStore(dbProvider, deploymentID) + return &service{ + store: store, + resolver: newResolver(store), + transactioner: transactioner, + timeouts: timeouts, + logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "SSOSessionService")), + }, nil +} diff --git a/backend/internal/flow/session/interface.go b/backend/internal/flow/session/interface.go new file mode 100644 index 0000000000..1484e611c4 --- /dev/null +++ b/backend/internal/flow/session/interface.go @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import "context" + +// sessionStore is the package-private persistence contract covering SSO sessions, their +// per-checkpoint session contexts, and their participants. A single operation-DB-backed +// implementation (store) satisfies it, and the service depends on this one interface. It is not +// used outside the package. +type sessionStore interface { + // Create persists a new session. + Create(ctx context.Context, s Session) error + // GetByHandle fetches a session by its opaque handle ID. It returns (nil, nil) when no + // session matches; liveness checks are the resolver's responsibility. + GetByHandle(ctx context.Context, handleID string) (*Session, error) + // GetByExecutionID fetches the session established by the given flow execution, or (nil, nil) + // when that execution has not established one. + GetByExecutionID(ctx context.Context, flowExecutionID string) (*Session, error) + // Update writes the mutable fields of an existing session under an optimistic-lock guard. It + // returns errVersionConflict when the stored version no longer matches, and bumps the in-memory + // Version on success. + Update(ctx context.Context, s *Session) error + + // CreateContext persists (or overwrites) one checkpoint's session context for a session. + CreateContext(ctx context.Context, c SessionContext) error + // GetByCheckpoint fetches one checkpoint's session context. It returns (nil, nil) when none exists. + GetByCheckpoint(ctx context.Context, sessionID, checkpointID string) (*SessionContext, error) + // Delete removes all of a session's checkpoint contexts. + Delete(ctx context.Context, sessionID string) error + // ListCheckpointIDs returns the checkpoint ids a session has saved, without loading any context + // payload — the existence check the SSO-Check node uses to decide checkpoint availability. + ListCheckpointIDs(ctx context.Context, sessionID string) ([]string, error) + + // Record inserts the participant, or refreshes its LAST_ACTIVE_AT (preserving FIRST_JOINED_AT) + // when the application has already joined the session. + Record(ctx context.Context, p Participant) error + // ListBySessionID returns the applications that have joined the session, oldest first. + ListBySessionID(ctx context.Context, sessionID string) ([]Participant, error) + // DeleteBySessionID removes all participants of a session. + DeleteBySessionID(ctx context.Context, sessionID string) error +} diff --git a/backend/internal/flow/session/model.go b/backend/internal/flow/session/model.go new file mode 100644 index 0000000000..38b0ce3aa0 --- /dev/null +++ b/backend/internal/flow/session/model.go @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "time" +) + +// State represents the lifecycle state of a session. +type State string + +const ( + // StateActive indicates the session is live and may back an SSO decision. + StateActive State = "ACTIVE" + // StateRevoked indicates the session was explicitly revoked and must not be resumed. + StateRevoked State = "REVOKED" + // StateEnded indicates the session ended (e.g. logout) and must not be resumed. + StateEnded State = "ENDED" +) + +// Session is the lean, hot-path SSO session entity. It carries only operational fields plus +// authenticated_at (used by the max_age policy check), so the resolve, SSO-check, and +// activity-touch paths never load the durable session context. +// +// The write-once auth-event facts (completed steps + sanitized claim snapshot) live in the +// sibling SessionContext (SESSION_AUTH_CONTEXT, 1:1 by session id), loaded only on the SSO path. +type Session struct { + // SessionID is the internal primary key, never exposed to clients. + SessionID string + // SubjectID is the authenticated subject (user) the session belongs to. + SubjectID string + // FlowID is the flow this session is grouped under (the SSO group key). + FlowID string + // FlowVersion is the flow definition version the session was established at. + FlowVersion int + // FlowExecutionID is the id of the flow execution that established this session. It is unique per + // session (enforced by a DB constraint), so concurrent joins within one execution converge on a + // single session instead of minting duplicates. It is set once at establishment and never changes + // on reuse by later executions. + FlowExecutionID string + + // HandleID is the opaque handle that references this session (the cookie value). It has no + // expiry of its own; session lifetime is governed by the idle and absolute deadlines. + HandleID string + + // AuthenticatedAt is when the subject most recently authenticated for this session. + AuthenticatedAt time.Time + // CreatedAt is when the session row was created. + CreatedAt time.Time + // LastActiveAt is refreshed each time the session backs a flow execution. + LastActiveAt time.Time + + // IdleExpiresAt slides forward on each activity touch; AbsoluteExpiresAt is fixed at creation. + // Both are enforced by the resolver, which rejects a session past either deadline. + IdleExpiresAt time.Time + AbsoluteExpiresAt time.Time + + // State is the lifecycle state of the session. + State State + // Version is the optimistic-lock token, incremented on every successful update. + Version int +} + +// Participant records an application that has used (joined) an SSO session. A session is shared +// across the applications that authenticate through its flow; each such application is tracked so +// the session's audience is known — the basis for logout and subject-scoped revocation. +type Participant struct { + // SessionID is the owning session's internal id. + SessionID string + // AppID is the participating application's id. + AppID string + // FirstJoinedAt is when the application first joined the session (write-once). + FirstJoinedAt time.Time + // LastActiveAt is refreshed each time the application reuses the session. + LastActiveAt time.Time +} + +// SSOInputs are the transient, request-scoped inputs the SSO-Check and Session nodes need to resolve +// or establish a session: the inbound handle for the current flow and the flow's identity/version +// (the SSO group key). They are carried on the Go context.Context rather than a NodeContext field, so +// they never persist with the flow context and never enter the reusable engine's public contract. +type SSOInputs struct { + // Handle is the inbound session handle carried for the current flow (empty if none). + Handle string + // FlowID is the current flow's id (the SSO group key). + FlowID string + // FlowVersion is the current active version of the flow definition. + FlowVersion int +} + +type ssoInputsContextKey struct{} + +// WithSSOInputs returns a context carrying the SSO inputs for the current flow execution. +func WithSSOInputs(ctx context.Context, in SSOInputs) context.Context { + return context.WithValue(ctx, ssoInputsContextKey{}, in) +} + +// SSOInputsFrom returns the SSO inputs carried on the context, or the zero value if none were set. +func SSOInputsFrom(ctx context.Context) SSOInputs { + if in, ok := ctx.Value(ssoInputsContextKey{}).(SSOInputs); ok { + return in + } + return SSOInputs{} +} diff --git a/backend/internal/flow/session/participant_store.go b/backend/internal/flow/session/participant_store.go new file mode 100644 index 0000000000..1cbbadef93 --- /dev/null +++ b/backend/internal/flow/session/participant_store.go @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "fmt" + + "github.com/thunder-id/thunderid/internal/system/database/provider" + sysutils "github.com/thunder-id/thunderid/internal/system/utils" +) + +// Record inserts or refreshes a participant under the upsert query. +func (st *store) Record(ctx context.Context, p Participant) error { + return withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + _, err := dbClient.ExecuteContext(ctx, queryUpsertParticipant, + p.SessionID, st.deploymentID, p.AppID, p.FirstJoinedAt, p.LastActiveAt) + if err != nil { + return fmt.Errorf("failed to record session participant: %w", err) + } + return nil + }) +} + +// ListBySessionID returns the participants of a session, oldest first. +func (st *store) ListBySessionID(ctx context.Context, sessionID string) ([]Participant, error) { + var result []Participant + + err := withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + results, queryErr := dbClient.QueryContext(ctx, queryListParticipantsBySessionID, sessionID, st.deploymentID) + if queryErr != nil { + return fmt.Errorf("failed to execute query: %w", queryErr) + } + for _, row := range results { + p, buildErr := buildParticipantFromRow(row) + if buildErr != nil { + return buildErr + } + result = append(result, p) + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// DeleteBySessionID removes all participants of a session. +func (st *store) DeleteBySessionID(ctx context.Context, sessionID string) error { + return withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + _, err := dbClient.ExecuteContext(ctx, queryDeleteParticipantsBySessionID, sessionID, st.deploymentID) + if err != nil { + return fmt.Errorf("failed to delete session participants: %w", err) + } + return nil + }) +} + +// buildParticipantFromRow maps a database result row into a Participant. +func buildParticipantFromRow(row map[string]interface{}) (Participant, error) { + sessionID, err := parseString(row["session_id"], "session_id") + if err != nil { + return Participant{}, err + } + appID, err := parseString(row["app_id"], "app_id") + if err != nil { + return Participant{}, err + } + firstJoinedAt, err := sysutils.ParseDBTimeField(row["first_joined_at"], "first_joined_at") + if err != nil { + return Participant{}, err + } + lastActiveAt, err := sysutils.ParseDBTimeField(row["last_active_at"], "last_active_at") + if err != nil { + return Participant{}, err + } + return Participant{ + SessionID: sessionID, + AppID: appID, + FirstJoinedAt: firstJoinedAt, + LastActiveAt: lastActiveAt, + }, nil +} diff --git a/backend/internal/flow/session/participant_store_test.go b/backend/internal/flow/session/participant_store_test.go new file mode 100644 index 0000000000..36dc537f00 --- /dev/null +++ b/backend/internal/flow/session/participant_store_test.go @@ -0,0 +1,195 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/tests/mocks/database/providermock" +) + +type ParticipantStoreTestSuite struct { + suite.Suite + mockDBProvider *providermock.DBProviderInterfaceMock + mockDBClient *providermock.DBClientInterfaceMock + store *store +} + +func TestParticipantStoreSuite(t *testing.T) { + suite.Run(t, new(ParticipantStoreTestSuite)) +} + +func (s *ParticipantStoreTestSuite) SetupTest() { + s.mockDBProvider = &providermock.DBProviderInterfaceMock{} + s.mockDBClient = &providermock.DBClientInterfaceMock{} + s.store = &store{ + dbProvider: s.mockDBProvider, + deploymentID: testDeploymentID, + } +} + +func (s *ParticipantStoreTestSuite) TestRecord_Upserts() { + now := time.Unix(1700000000, 0).UTC() + p := Participant{SessionID: "sess-1", AppID: "app-1", FirstJoinedAt: now, LastActiveAt: now} + + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryUpsertParticipant, + "sess-1", testDeploymentID, "app-1", now, now). + Return(int64(1), nil) + + err := s.store.Record(context.Background(), p) + + s.NoError(err) + // DEPLOYMENT_ID is the second positional parameter, matching the SSO query convention. + s.mockDBClient.AssertExpectations(s.T()) +} + +func (s *ParticipantStoreTestSuite) TestRecord_DBError() { + now := time.Unix(1700000000, 0).UTC() + p := Participant{SessionID: "sess-1", AppID: "app-1", FirstJoinedAt: now, LastActiveAt: now} + + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryUpsertParticipant, + "sess-1", testDeploymentID, "app-1", now, now). + Return(int64(0), errors.New("db down")) + + err := s.store.Record(context.Background(), p) + + s.Error(err) + s.Contains(err.Error(), "failed to record session participant") +} + +func (s *ParticipantStoreTestSuite) TestListBySessionID() { + first := time.Unix(1700000000, 0).UTC() + second := time.Unix(1700000100, 0).UTC() + rows := []map[string]interface{}{ + {"session_id": "sess-1", "app_id": "app-1", "first_joined_at": first, "last_active_at": first}, + {"session_id": "sess-1", "app_id": "app-2", "first_joined_at": second, "last_active_at": second}, + } + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryListParticipantsBySessionID, + "sess-1", testDeploymentID). + Return(rows, nil) + + got, err := s.store.ListBySessionID(context.Background(), "sess-1") + + s.NoError(err) + s.Require().Len(got, 2) + s.Equal("app-1", got[0].AppID) + s.Equal("app-2", got[1].AppID) + s.Equal(first, got[0].FirstJoinedAt) +} + +func (s *ParticipantStoreTestSuite) TestListBySessionID_Empty() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryListParticipantsBySessionID, + "sess-1", testDeploymentID). + Return([]map[string]interface{}{}, nil) + + got, err := s.store.ListBySessionID(context.Background(), "sess-1") + + s.NoError(err) + s.Empty(got) +} + +func (s *ParticipantStoreTestSuite) TestDeleteBySessionID() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryDeleteParticipantsBySessionID, + "sess-1", testDeploymentID). + Return(int64(2), nil) + + err := s.store.DeleteBySessionID(context.Background(), "sess-1") + + s.NoError(err) + s.mockDBClient.AssertExpectations(s.T()) +} + +func (s *ParticipantStoreTestSuite) TestListBySessionID_ClientError() { + s.mockDBProvider.On("GetOperationDBClient").Return(nil, errors.New("no client")) + + got, err := s.store.ListBySessionID(context.Background(), "sess-1") + + s.Error(err) + s.Nil(got) +} + +func (s *ParticipantStoreTestSuite) TestListBySessionID_QueryError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryListParticipantsBySessionID, + "sess-1", testDeploymentID). + Return(nil, errors.New("query failed")) + + got, err := s.store.ListBySessionID(context.Background(), "sess-1") + + s.Error(err) + s.Nil(got) +} + +func (s *ParticipantStoreTestSuite) TestListBySessionID_BuildError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryListParticipantsBySessionID, + "sess-1", testDeploymentID). + Return([]map[string]interface{}{{"session_id": 42}}, nil) // non-string id fails buildParticipantFromRow + + got, err := s.store.ListBySessionID(context.Background(), "sess-1") + + s.Error(err) + s.Nil(got) +} + +func (s *ParticipantStoreTestSuite) TestDeleteBySessionID_DBError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryDeleteParticipantsBySessionID, + "sess-1", testDeploymentID). + Return(int64(0), errors.New("db down")) + + err := s.store.DeleteBySessionID(context.Background(), "sess-1") + + s.Error(err) + s.Contains(err.Error(), "failed to delete session participants") +} + +func (s *ParticipantStoreTestSuite) TestBuildParticipantFromRow_BadFields() { + now := time.Unix(1700000000, 0).UTC() + valid := func() map[string]interface{} { + return map[string]interface{}{ + "session_id": "sess-1", "app_id": "app-1", "first_joined_at": now, "last_active_at": now, + } + } + _, err := buildParticipantFromRow(valid()) + s.Require().NoError(err) + + for _, f := range []string{"session_id", "app_id"} { + row := valid() + row[f] = 42 + _, buildErr := buildParticipantFromRow(row) + s.Error(buildErr, "expected error for bad %s", f) + } + for _, f := range []string{"first_joined_at", "last_active_at"} { + row := valid() + row[f] = 42 + _, buildErr := buildParticipantFromRow(row) + s.Error(buildErr, "expected error for bad %s", f) + } +} diff --git a/backend/internal/flow/session/resolver.go b/backend/internal/flow/session/resolver.go new file mode 100644 index 0000000000..c4436f9012 --- /dev/null +++ b/backend/internal/flow/session/resolver.go @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "time" +) + +// Resolver loads a session from an opaque handle and returns it only when it is live. It does +// not check flow identity or version — that is the SSO-Check node's responsibility. +type Resolver interface { + // Resolve returns the session referenced by handleID when it is ACTIVE and within its + // deadlines at now. It returns (nil, nil) for every "no live session" case (absent, + // ended/revoked, expired), and a non-nil error only on a store failure. + Resolve(ctx context.Context, handleID string, now time.Time) (*Session, error) +} + +type resolver struct { + store sessionStore +} + +// newResolver creates a Resolver backed by the given session store. +func newResolver(store sessionStore) Resolver { + return &resolver{store: store} +} + +// Resolve implements Resolver. +func (r *resolver) Resolve(ctx context.Context, handleID string, now time.Time) (*Session, error) { + if handleID == "" { + return nil, nil + } + + s, err := r.store.GetByHandle(ctx, handleID) + if err != nil { + return nil, err + } + if s == nil { + return nil, nil + } + + if s.State != StateActive { + return nil, nil + } + if expired(s.IdleExpiresAt, now) || expired(s.AbsoluteExpiresAt, now) { + return nil, nil + } + + return s, nil +} + +// expired reports whether a deadline is set and has been reached at now. A zero deadline +// means "no deadline" and never expires. +func expired(deadline, now time.Time) bool { + return !deadline.IsZero() && !now.Before(deadline) +} diff --git a/backend/internal/flow/session/resolver_test.go b/backend/internal/flow/session/resolver_test.go new file mode 100644 index 0000000000..79fe5361ed --- /dev/null +++ b/backend/internal/flow/session/resolver_test.go @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/tests/mocks/database/providermock" +) + +type ResolverTestSuite struct { + suite.Suite + mockDBProvider *providermock.DBProviderInterfaceMock + mockDBClient *providermock.DBClientInterfaceMock + resolver Resolver + now time.Time +} + +func TestResolverTestSuite(t *testing.T) { + suite.Run(t, new(ResolverTestSuite)) +} + +func (s *ResolverTestSuite) SetupTest() { + s.mockDBProvider = &providermock.DBProviderInterfaceMock{} + s.mockDBClient = &providermock.DBClientInterfaceMock{} + s.resolver = newResolver(newStore(s.mockDBProvider, testDeploymentID)) + s.now = time.Date(2026, 6, 17, 12, 0, 0, 0, time.UTC) +} + +// row builds a live session result row with the given state; idle/absolute deadlines default to +// open (nil) and are set per-test to exercise expiry. +func (s *ResolverTestSuite) row(state string) map[string]interface{} { + return map[string]interface{}{ + "session_id": "sess-1", + "subject_id": "user-1", + "flow_id": "flow-1", + "flow_version": int64(1), + "flow_execution_id": "exec-1", + "handle_id": "handle-abc", + "authenticated_at": s.now.Add(-time.Minute), + "created_at": s.now.Add(-time.Minute), + "last_active_at": s.now.Add(-time.Minute), + "idle_expires_at": nil, + "absolute_expires_at": nil, + "state": state, + "version": int64(1), + } +} + +func (s *ResolverTestSuite) expectQuery(rows []map[string]interface{}, err error) { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionByHandle, + "handle-abc", testDeploymentID).Return(rows, err) +} + +func (s *ResolverTestSuite) TestResolve_Hit() { + s.expectQuery([]map[string]interface{}{s.row("ACTIVE")}, nil) + + got, err := s.resolver.Resolve(context.Background(), "handle-abc", s.now) + + s.NoError(err) + s.Require().NotNil(got) + s.Equal("sess-1", got.SessionID) + s.Equal("flow-1", got.FlowID) + // The resolve hot path reads SESSION only — it must never load the session context. + s.mockDBClient.AssertNotCalled(s.T(), "QueryContext", mock.Anything, + queryGetSessionContextByCheckpoint, mock.Anything) +} + +func (s *ResolverTestSuite) TestResolve_EmptyHandle() { + got, err := s.resolver.Resolve(context.Background(), "", s.now) + + s.NoError(err) + s.Nil(got) + s.mockDBProvider.AssertNotCalled(s.T(), "GetOperationDBClient") +} + +func (s *ResolverTestSuite) TestResolve_AbsentNoRow() { + s.expectQuery([]map[string]interface{}{}, nil) + + got, err := s.resolver.Resolve(context.Background(), "handle-abc", s.now) + + s.NoError(err) + s.Nil(got) +} + +func (s *ResolverTestSuite) TestResolve_Ended() { + s.expectQuery([]map[string]interface{}{s.row("ENDED")}, nil) + + got, err := s.resolver.Resolve(context.Background(), "handle-abc", s.now) + + s.NoError(err) + s.Nil(got) +} + +func (s *ResolverTestSuite) TestResolve_IdleExpired() { + r := s.row("ACTIVE") + r["idle_expires_at"] = s.now.Add(-time.Minute) + s.expectQuery([]map[string]interface{}{r}, nil) + + got, err := s.resolver.Resolve(context.Background(), "handle-abc", s.now) + + s.NoError(err) + s.Nil(got, "a session past its idle deadline must not resolve") +} + +func (s *ResolverTestSuite) TestResolve_AbsoluteExpired() { + r := s.row("ACTIVE") + r["absolute_expires_at"] = s.now.Add(-time.Minute) + s.expectQuery([]map[string]interface{}{r}, nil) + + got, err := s.resolver.Resolve(context.Background(), "handle-abc", s.now) + + s.NoError(err) + s.Nil(got, "a session past its absolute deadline must not resolve") +} + +func (s *ResolverTestSuite) TestResolve_StoreError() { + s.expectQuery(nil, errors.New("db down")) + + got, err := s.resolver.Resolve(context.Background(), "handle-abc", s.now) + + s.Error(err) + s.Nil(got) +} diff --git a/backend/internal/flow/session/service.go b/backend/internal/flow/session/service.go new file mode 100644 index 0000000000..27ec895c4f --- /dev/null +++ b/backend/internal/flow/session/service.go @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package session provides the persistent SSO session model and relational store. +// +// A session is the unit that carries authenticated state across separate flow +// executions. It is grouped by flow: the flow ID is the group key, so only +// applications configured with the same flow can share a session (SSO). The +// session is referenced by an opaque handle, decoupled from the transport that +// carries it (a cookie is one such transport; see HandleTransport). +package session + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/thunder-id/thunderid/internal/system/cryptolib" + "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/internal/system/transaction" + sysutils "github.com/thunder-id/thunderid/internal/system/utils" +) + +// Service is the SSO session capability. It wraps every session-store operation so callers (the +// flow executors) depend only on this interface and never touch the stores directly. Construct it +// with Initialize. +type Service interface { + // Resolve returns the live session for the given flow, or nil when none applies: no or expired + // session, a session from a different flow, or one established at an incompatible flow version. + Resolve(ctx context.Context, handle, flowID string, flowVersion int, now time.Time) (*Session, error) + + // HasCheckpoint reports whether the resolved session already holds a snapshot for the checkpoint, + // using the decrypt-free checkpoint listing. + HasCheckpoint(ctx context.Context, sessionID, checkpoint string) (bool, error) + + // SaveCheckpoint attaches the checkpoint to this flow execution's session — the one already + // resolved (via HandleHint), one an earlier join minted, or a freshly established one — writing + // the checkpoint context and the joining participant in a single transaction. Result.Skipped is + // true when the authenticated subject conflicts with the existing session's subject. + SaveCheckpoint(ctx context.Context, in SaveCheckpointInput) (SaveCheckpointResult, error) + + // LoadCheckpoint fetches the session referenced by handle and its checkpoint context, refreshes + // the session's last-active timestamp and idle deadline, and records the joining participant + // (both best-effort). It errors when the session or its checkpoint context no longer exists. + LoadCheckpoint(ctx context.Context, handle, checkpoint, appID string) (*Session, *SessionContext, error) +} + +// SaveCheckpointInput carries the data a Session join needs to persist. The caller resolves the +// subject and builds the (already sanitized) snapshot; the service only stores it. +type SaveCheckpointInput struct { + SubjectID string + FlowID string + FlowVersion int + ExecutionID string + HandleHint string // shared handle for this execution, or "" to look up by execution id + Checkpoint string + AuthUser json.RawMessage + RuntimeData map[string]string + CompletedSteps map[string]StepFact + AppID string +} + +// SaveCheckpointResult reports the outcome of a save. Handle is the session's handle; Created is +// true only when this call minted the session (so the caller emits the cookie); Skipped is true +// when the save was declined because of a subject mismatch. +type SaveCheckpointResult struct { + Handle string + Created bool + Skipped bool +} + +// service is the store-backed implementation of Service. +type service struct { + store sessionStore + resolver Resolver + transactioner transaction.Transactioner + timeouts Timeouts + logger *log.Logger +} + +var _ Service = (*service)(nil) + +// Resolve implements Service. +func (s *service) Resolve(ctx context.Context, handle, flowID string, flowVersion int, + now time.Time) (*Session, error) { + if handle == "" { + return nil, nil + } + sess, err := s.resolver.Resolve(ctx, handle, now) + if err != nil { + return nil, fmt.Errorf("failed to resolve SSO session: %w", err) + } + if sess == nil { + return nil, nil + } + if sess.FlowID != flowID { + s.logger.Debug(ctx, "Resolved session belongs to a different flow; ignoring") + return nil, nil + } + if sess.FlowVersion != flowVersion { + s.logger.Debug(ctx, "Resolved session has an incompatible flow version; forcing full authentication") + return nil, nil + } + return sess, nil +} + +// HasCheckpoint implements Service. +func (s *service) HasCheckpoint(ctx context.Context, sessionID, checkpoint string) (bool, error) { + ids, err := s.store.ListCheckpointIDs(ctx, sessionID) + if err != nil { + return false, fmt.Errorf("failed to list SSO session checkpoints: %w", err) + } + for _, id := range ids { + if id == checkpoint { + return true, nil + } + } + return false, nil +} + +// SaveCheckpoint implements Service. +func (s *service) SaveCheckpoint(ctx context.Context, in SaveCheckpointInput) (SaveCheckpointResult, error) { + target, created, err := s.targetSession(ctx, in) + if err != nil { + return SaveCheckpointResult{}, err + } + if target == nil { + return SaveCheckpointResult{Skipped: true}, nil + } + + snapshot := SessionContext{ + SessionID: target.SessionID, + CheckpointID: in.Checkpoint, + RuntimeData: in.RuntimeData, + AuthUser: in.AuthUser, + CompletedSteps: in.CompletedSteps, + ContextVersion: 1, + } + + // Write this checkpoint's context (upsert) and the joining participant in one transaction. + now := time.Now().UTC() + if err := s.transactioner.Transact(ctx, func(txCtx context.Context) error { + if err := s.store.CreateContext(txCtx, snapshot); err != nil { + return err + } + return s.recordParticipant(txCtx, target.SessionID, in.AppID, now) + }); err != nil { + return SaveCheckpointResult{}, err + } + + s.logger.Debug(ctx, "Saved SSO checkpoint", log.String("checkpoint", in.Checkpoint)) + return SaveCheckpointResult{Handle: target.HandleID, Created: created}, nil +} + +// LoadCheckpoint implements Service. +func (s *service) LoadCheckpoint(ctx context.Context, handle, checkpoint, appID string) ( + *Session, *SessionContext, error) { + if handle == "" { + return nil, nil, fmt.Errorf("no resolved session handle to load") + } + sess, err := s.store.GetByHandle(ctx, handle) + if err != nil { + return nil, nil, err + } + if sess == nil { + return nil, nil, fmt.Errorf("resolved session no longer exists") + } + + // Lazily load this checkpoint's durable session context (only the load path reads it). + sc, err := s.store.GetByCheckpoint(ctx, sess.SessionID, checkpoint) + if err != nil { + return nil, nil, err + } + if sc == nil { + return nil, nil, fmt.Errorf("session context for checkpoint %q no longer exists", checkpoint) + } + + // Refresh last-active and slide the idle deadline under the optimistic-lock guard — touches + // SESSION only. The absolute deadline is left unchanged so it keeps capping total lifetime. A + // conflict here is non-fatal: the session loaded successfully. + now := time.Now().UTC() + sess.LastActiveAt = now + sess.IdleExpiresAt = now.Add(s.timeouts.Idle) + if updErr := s.store.Update(ctx, sess); updErr != nil { + s.logger.Warn(ctx, "Failed to refresh session last-active timestamp", log.Error(updErr)) + } + + // Record the joining application as a participant. Best-effort: the session loaded fine even if + // this fails. + if partErr := s.recordParticipant(ctx, sess.SessionID, appID, now); partErr != nil { + s.logger.Warn(ctx, "Failed to record SSO session participant", log.Error(partErr)) + } + + return sess, sc, nil +} + +// targetSession returns the session this execution's checkpoints attach to, establishing one when +// none exists yet. The bool reports whether this call minted the session. It returns (nil, false, +// nil) when an existing session belongs to a different subject than the one just authenticated, so +// the caller skips the save rather than cross-attaching. +func (s *service) targetSession(ctx context.Context, in SaveCheckpointInput) (*Session, bool, error) { + existing, err := s.existingSession(ctx, in.HandleHint, in.ExecutionID) + if err != nil { + return nil, false, err + } + if existing != nil { + if existing.SubjectID != in.SubjectID { + s.logger.Warn(ctx, + "Authenticated subject differs from the SSO session subject; not attaching checkpoint") + return nil, false, nil + } + return existing, false, nil + } + return s.establishSession(ctx, in) +} + +// existingSession returns the session already backing this execution: the one referenced by the +// shared handle hint, else the one recorded against this flow execution id. Returns (nil, nil) when +// none exists yet. +func (s *service) existingSession(ctx context.Context, handleHint, executionID string) (*Session, error) { + if handleHint != "" { + return s.store.GetByHandle(ctx, handleHint) + } + return s.store.GetByExecutionID(ctx, executionID) +} + +// establishSession mints and inserts a new session for this flow execution. The insert is idempotent +// on the flow execution id, so under concurrency it re-reads and returns whichever session won the +// race; the returned bool is true only when this call minted the winner. +func (s *service) establishSession(ctx context.Context, in SaveCheckpointInput) (*Session, bool, error) { + sessionID, err := sysutils.GenerateUUIDv7() + if err != nil { + return nil, false, fmt.Errorf("failed to generate session id: %w", err) + } + handle, err := cryptolib.GenerateSecureToken() + if err != nil { + return nil, false, fmt.Errorf("failed to generate session handle: %w", err) + } + + now := time.Now().UTC() + newSession := Session{ + SessionID: sessionID, + SubjectID: in.SubjectID, + FlowID: in.FlowID, + FlowVersion: in.FlowVersion, + FlowExecutionID: in.ExecutionID, + HandleID: handle, + AuthenticatedAt: now, + CreatedAt: now, + LastActiveAt: now, + // The idle deadline slides on each activity touch; the absolute deadline is fixed here and + // caps the session's total lifetime. The resolver rejects a session past either deadline. + IdleExpiresAt: now.Add(s.timeouts.Idle), + AbsoluteExpiresAt: now.Add(s.timeouts.Absolute), + State: StateActive, + Version: 1, + } + if err := s.store.Create(ctx, newSession); err != nil { + return nil, false, err + } + + // Re-read the session that actually persisted for this execution: the insert is a no-op when a + // concurrent join already established one, so this returns the winner (this call's row or the racer's). + established, err := s.store.GetByExecutionID(ctx, in.ExecutionID) + if err != nil { + return nil, false, err + } + if established == nil { + return nil, false, fmt.Errorf("session establishment did not persist for execution %q", in.ExecutionID) + } + created := established.HandleID == handle + if created { + s.logger.Debug(ctx, "Established SSO session", log.String("flowId", in.FlowID)) + } + return established, created, nil +} + +// recordParticipant records the application as a participant of the session, refreshing its +// last-active time if it has joined before. It is a no-op when the application id is unknown. +func (s *service) recordParticipant(ctx context.Context, sessionID, appID string, now time.Time) error { + if appID == "" { + return nil + } + return s.store.Record(ctx, Participant{ + SessionID: sessionID, + AppID: appID, + FirstJoinedAt: now, + LastActiveAt: now, + }) +} diff --git a/backend/internal/flow/session/service_test.go b/backend/internal/flow/session/service_test.go new file mode 100644 index 0000000000..702153c56b --- /dev/null +++ b/backend/internal/flow/session/service_test.go @@ -0,0 +1,351 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/internal/system/config" + "github.com/thunder-id/thunderid/internal/system/log" + "github.com/thunder-id/thunderid/tests/mocks/transactionmock" +) + +type ServiceTestSuite struct { + suite.Suite +} + +func TestServiceTestSuite(t *testing.T) { + suite.Run(t, new(ServiceTestSuite)) +} + +func (suite *ServiceTestSuite) SetupTest() { + suite.Require().NoError(config.InitializeServerRuntime(suite.T().TempDir(), &config.Config{})) +} + +func (suite *ServiceTestSuite) TearDownTest() { + config.ResetServerRuntime() +} + +// serviceMocks bundles the generated store/transaction mocks a service test wires together. The one +// store mock backs every persistence operation (sessions, contexts, participants) since the service +// depends on the single sessionStore interface. +type serviceMocks struct { + store *sessionStoreMock + tx *transactionmock.TransactionerMock +} + +func (suite *ServiceTestSuite) newService() (*service, *serviceMocks) { + m := &serviceMocks{ + store: newSessionStoreMock(suite.T()), + tx: transactionmock.NewTransactionerMock(suite.T()), + } + svc := &service{ + store: m.store, + resolver: newResolver(m.store), + transactioner: m.tx, + timeouts: DefaultTimeouts(), + logger: log.GetLogger(), + } + return svc, m +} + +// runTx makes the transaction mock execute the callback it is handed (commit-on-success semantics). +func runTx(m *serviceMocks) { + m.tx.EXPECT().Transact(mock.Anything, mock.Anything).RunAndReturn( + func(ctx context.Context, fn func(context.Context) error) error { return fn(ctx) }) +} + +func liveStoreSession() *Session { + return &Session{ + SessionID: "sess-1", SubjectID: "user-1", HandleID: "handle-abc", + FlowID: "flow-1", FlowVersion: 3, State: StateActive, + } +} + +// --- Resolve --- + +func (suite *ServiceTestSuite) TestResolve_Hit() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, "handle-abc").Return(liveStoreSession(), nil) + + got, err := svc.Resolve(context.Background(), "handle-abc", "flow-1", 3, time.Now().UTC()) + + suite.Require().NoError(err) + suite.Require().NotNil(got) + suite.Equal("sess-1", got.SessionID) +} + +func (suite *ServiceTestSuite) TestResolve_NoHandle() { + svc, _ := suite.newService() + + got, err := svc.Resolve(context.Background(), "", "flow-1", 3, time.Now().UTC()) + + suite.Require().NoError(err) + suite.Nil(got) +} + +func (suite *ServiceTestSuite) TestResolve_DifferentFlow() { + svc, m := suite.newService() + s := liveStoreSession() + s.FlowID = "other-flow" + m.store.EXPECT().GetByHandle(mock.Anything, mock.Anything).Return(s, nil) + + got, err := svc.Resolve(context.Background(), "handle-abc", "flow-1", 3, time.Now().UTC()) + + suite.Require().NoError(err) + suite.Nil(got, "a session from a different flow must not be reused") +} + +func (suite *ServiceTestSuite) TestResolve_VersionMismatch() { + svc, m := suite.newService() + s := liveStoreSession() + s.FlowVersion = 2 + m.store.EXPECT().GetByHandle(mock.Anything, mock.Anything).Return(s, nil) + + got, err := svc.Resolve(context.Background(), "handle-abc", "flow-1", 3, time.Now().UTC()) + + suite.Require().NoError(err) + suite.Nil(got, "an incompatible flow version must force full authentication") +} + +func (suite *ServiceTestSuite) TestResolve_StoreError() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, mock.Anything).Return(nil, errors.New("store down")) + + _, err := svc.Resolve(context.Background(), "handle-abc", "flow-1", 3, time.Now().UTC()) + + suite.Require().Error(err) + suite.Contains(err.Error(), "failed to resolve SSO session") +} + +// --- HasCheckpoint --- + +func (suite *ServiceTestSuite) TestHasCheckpoint_Present() { + svc, m := suite.newService() + m.store.EXPECT().ListCheckpointIDs(mock.Anything, "sess-1").Return([]string{"password", "session"}, nil) + + present, err := svc.HasCheckpoint(context.Background(), "sess-1", "session") + suite.Require().NoError(err) + suite.True(present) +} + +func (suite *ServiceTestSuite) TestHasCheckpoint_Absent() { + svc, m := suite.newService() + m.store.EXPECT().ListCheckpointIDs(mock.Anything, "sess-1").Return([]string{"password"}, nil) + + present, err := svc.HasCheckpoint(context.Background(), "sess-1", "session") + suite.Require().NoError(err) + suite.False(present) +} + +func (suite *ServiceTestSuite) TestHasCheckpoint_ListError() { + svc, m := suite.newService() + m.store.EXPECT().ListCheckpointIDs(mock.Anything, mock.Anything).Return(nil, errors.New("store down")) + + _, err := svc.HasCheckpoint(context.Background(), "sess-1", "session") + + suite.Require().Error(err) + suite.Contains(err.Error(), "failed to list SSO session checkpoints") +} + +// --- SaveCheckpoint --- + +func saveInput() SaveCheckpointInput { + return SaveCheckpointInput{ + SubjectID: "user-1", FlowID: "flow-1", FlowVersion: 3, ExecutionID: "exec-1", + Checkpoint: "session", AuthUser: json.RawMessage(`{"entityReference":{"entityId":"user-1"}}`), + RuntimeData: map[string]string{"email": "alice@example.com"}, AppID: "app-123", + } +} + +func (suite *ServiceTestSuite) TestSaveCheckpoint_Establishes() { + svc, m := suite.newService() + // Model the establish sequence: GetByExecutionID returns nil until Create persists the row, then + // returns it (so the re-read finds this call's own session and reports Created). + var created *Session + m.store.EXPECT().GetByExecutionID(mock.Anything, mock.Anything).RunAndReturn( + func(context.Context, string) (*Session, error) { return created, nil }) + m.store.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, s Session) error { created = &s; return nil }) + runTx(m) + m.store.EXPECT().CreateContext(mock.Anything, mock.Anything).Return(nil) + m.store.EXPECT().Record(mock.Anything, mock.Anything).Return(nil) + + res, err := svc.SaveCheckpoint(context.Background(), saveInput()) + suite.Require().NoError(err) + + suite.True(res.Created) + suite.NotEmpty(res.Handle) + suite.Require().NotNil(created) + suite.Equal("user-1", created.SubjectID) + suite.Equal("flow-1", created.FlowID) + suite.Equal(3, created.FlowVersion) + suite.Equal(StateActive, created.State) + suite.True(created.IdleExpiresAt.After(created.CreatedAt)) + suite.True(created.AbsoluteExpiresAt.After(created.IdleExpiresAt)) +} + +func (suite *ServiceTestSuite) TestSaveCheckpoint_AttachesToExisting() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, "handle-abc").Return(liveStoreSession(), nil) + runTx(m) + var savedCtx SessionContext + m.store.EXPECT().CreateContext(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, c SessionContext) error { savedCtx = c; return nil }) + m.store.EXPECT().Record(mock.Anything, mock.Anything).Return(nil) + + in := saveInput() + in.Checkpoint = "step_up" + in.HandleHint = "handle-abc" + + res, err := svc.SaveCheckpoint(context.Background(), in) + suite.Require().NoError(err) + + suite.False(res.Created, "attaching to an existing session must not mint a new one") + suite.Equal("handle-abc", res.Handle) + suite.Equal("sess-1", savedCtx.SessionID) + suite.Equal("step_up", savedCtx.CheckpointID) +} + +func (suite *ServiceTestSuite) TestSaveCheckpoint_SubjectMismatchSkips() { + svc, m := suite.newService() + existing := liveStoreSession() + existing.SubjectID = "someone-else" + m.store.EXPECT().GetByHandle(mock.Anything, "handle-abc").Return(existing, nil) + + in := saveInput() + in.HandleHint = "handle-abc" + + res, err := svc.SaveCheckpoint(context.Background(), in) + suite.Require().NoError(err) + + suite.True(res.Skipped, "a subject mismatch must not cross-attach") +} + +func (suite *ServiceTestSuite) TestSaveCheckpoint_EstablishError() { + svc, m := suite.newService() + m.store.EXPECT().GetByExecutionID(mock.Anything, mock.Anything).Return(nil, nil) + m.store.EXPECT().Create(mock.Anything, mock.Anything).Return(errors.New("insert failed")) + + _, err := svc.SaveCheckpoint(context.Background(), saveInput()) + + suite.Require().Error(err) +} + +func (suite *ServiceTestSuite) TestSaveCheckpoint_ContextWriteError() { + svc, m := suite.newService() + var created *Session + m.store.EXPECT().GetByExecutionID(mock.Anything, mock.Anything).RunAndReturn( + func(context.Context, string) (*Session, error) { return created, nil }) + m.store.EXPECT().Create(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, s Session) error { created = &s; return nil }) + runTx(m) + m.store.EXPECT().CreateContext(mock.Anything, mock.Anything).Return(errors.New("db down")) + + _, err := svc.SaveCheckpoint(context.Background(), saveInput()) + + suite.Require().Error(err) +} + +// --- LoadCheckpoint --- + +func (suite *ServiceTestSuite) TestLoadCheckpoint_Success() { + originalIdle := time.Unix(1700000600, 0).UTC() + originalAbsolute := time.Unix(1700050000, 0).UTC() + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, "handle-abc").Return(&Session{ + SessionID: "sess-1", HandleID: "handle-abc", AuthenticatedAt: time.Unix(1700000000, 0).UTC(), + IdleExpiresAt: originalIdle, AbsoluteExpiresAt: originalAbsolute, State: StateActive, + }, nil) + m.store.EXPECT().GetByCheckpoint(mock.Anything, "sess-1", "session"). + Return(&SessionContext{SessionID: "sess-1"}, nil) + var updated *Session + m.store.EXPECT().Update(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, s *Session) error { updated = s; return nil }) + var recorded Participant + m.store.EXPECT().Record(mock.Anything, mock.Anything).RunAndReturn( + func(_ context.Context, p Participant) error { recorded = p; return nil }) + + sess, sc, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456") + suite.Require().NoError(err) + + suite.Require().NotNil(sess) + suite.Require().NotNil(sc) + // Activity touch: last-active refreshed, idle slid forward, absolute unchanged. + suite.Require().NotNil(updated) + suite.False(updated.LastActiveAt.IsZero()) + suite.True(updated.IdleExpiresAt.After(originalIdle)) + suite.Equal(originalAbsolute, updated.AbsoluteExpiresAt) + // The joining application is recorded. + suite.Equal("app-456", recorded.AppID) +} + +func (suite *ServiceTestSuite) TestLoadCheckpoint_NoHandle() { + svc, _ := suite.newService() + + _, _, err := svc.LoadCheckpoint(context.Background(), "", "session", "app-456") + + suite.Require().Error(err) + suite.Contains(err.Error(), "no resolved session handle") +} + +func (suite *ServiceTestSuite) TestLoadCheckpoint_MissingSession() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, mock.Anything).Return(nil, nil) + + _, _, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456") + + suite.Require().Error(err) + suite.Contains(err.Error(), "resolved session no longer exists") +} + +func (suite *ServiceTestSuite) TestLoadCheckpoint_MissingContext() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, mock.Anything). + Return(&Session{SessionID: "sess-1", HandleID: "handle-abc", State: StateActive}, nil) + m.store.EXPECT().GetByCheckpoint(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) + + _, _, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456") + + suite.Require().Error(err) + suite.Contains(err.Error(), "session context for checkpoint") +} + +func (suite *ServiceTestSuite) TestLoadCheckpoint_ParticipantErrorIsNonFatal() { + svc, m := suite.newService() + m.store.EXPECT().GetByHandle(mock.Anything, mock.Anything). + Return(&Session{SessionID: "sess-1", HandleID: "handle-abc", State: StateActive}, nil) + m.store.EXPECT().GetByCheckpoint(mock.Anything, mock.Anything, mock.Anything). + Return(&SessionContext{SessionID: "sess-1"}, nil) + m.store.EXPECT().Update(mock.Anything, mock.Anything).Return(nil) + m.store.EXPECT().Record(mock.Anything, mock.Anything).Return(errors.New("db down")) + + sess, sc, err := svc.LoadCheckpoint(context.Background(), "handle-abc", "session", "app-456") + + suite.Require().NoError(err, "a participant-record failure must not fail the load") + suite.NotNil(sess) + suite.NotNil(sc) +} diff --git a/backend/internal/flow/session/sessionStore_mock_test.go b/backend/internal/flow/session/sessionStore_mock_test.go new file mode 100644 index 0000000000..0816c980e6 --- /dev/null +++ b/backend/internal/flow/session/sessionStore_mock_test.go @@ -0,0 +1,726 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package session + +import ( + "context" + + mock "github.com/stretchr/testify/mock" +) + +// newSessionStoreMock creates a new instance of sessionStoreMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func newSessionStoreMock(t interface { + mock.TestingT + Cleanup(func()) +}) *sessionStoreMock { + mock := &sessionStoreMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// sessionStoreMock is an autogenerated mock type for the sessionStore type +type sessionStoreMock struct { + mock.Mock +} + +type sessionStoreMock_Expecter struct { + mock *mock.Mock +} + +func (_m *sessionStoreMock) EXPECT() *sessionStoreMock_Expecter { + return &sessionStoreMock_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) Create(ctx context.Context, s Session) error { + ret := _mock.Called(ctx, s) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, Session) error); ok { + r0 = returnFunc(ctx, s) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// sessionStoreMock_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type sessionStoreMock_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - s Session +func (_e *sessionStoreMock_Expecter) Create(ctx interface{}, s interface{}) *sessionStoreMock_Create_Call { + return &sessionStoreMock_Create_Call{Call: _e.mock.On("Create", ctx, s)} +} + +func (_c *sessionStoreMock_Create_Call) Run(run func(ctx context.Context, s Session)) *sessionStoreMock_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 Session + if args[1] != nil { + arg1 = args[1].(Session) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_Create_Call) Return(err error) *sessionStoreMock_Create_Call { + _c.Call.Return(err) + return _c +} + +func (_c *sessionStoreMock_Create_Call) RunAndReturn(run func(ctx context.Context, s Session) error) *sessionStoreMock_Create_Call { + _c.Call.Return(run) + return _c +} + +// CreateContext provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) CreateContext(ctx context.Context, c SessionContext) error { + ret := _mock.Called(ctx, c) + + if len(ret) == 0 { + panic("no return value specified for CreateContext") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, SessionContext) error); ok { + r0 = returnFunc(ctx, c) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// sessionStoreMock_CreateContext_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateContext' +type sessionStoreMock_CreateContext_Call struct { + *mock.Call +} + +// CreateContext is a helper method to define mock.On call +// - ctx context.Context +// - c SessionContext +func (_e *sessionStoreMock_Expecter) CreateContext(ctx interface{}, c interface{}) *sessionStoreMock_CreateContext_Call { + return &sessionStoreMock_CreateContext_Call{Call: _e.mock.On("CreateContext", ctx, c)} +} + +func (_c *sessionStoreMock_CreateContext_Call) Run(run func(ctx context.Context, c SessionContext)) *sessionStoreMock_CreateContext_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 SessionContext + if args[1] != nil { + arg1 = args[1].(SessionContext) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_CreateContext_Call) Return(err error) *sessionStoreMock_CreateContext_Call { + _c.Call.Return(err) + return _c +} + +func (_c *sessionStoreMock_CreateContext_Call) RunAndReturn(run func(ctx context.Context, c SessionContext) error) *sessionStoreMock_CreateContext_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) Delete(ctx context.Context, sessionID string) error { + ret := _mock.Called(ctx, sessionID) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, sessionID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// sessionStoreMock_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type sessionStoreMock_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +func (_e *sessionStoreMock_Expecter) Delete(ctx interface{}, sessionID interface{}) *sessionStoreMock_Delete_Call { + return &sessionStoreMock_Delete_Call{Call: _e.mock.On("Delete", ctx, sessionID)} +} + +func (_c *sessionStoreMock_Delete_Call) Run(run func(ctx context.Context, sessionID string)) *sessionStoreMock_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_Delete_Call) Return(err error) *sessionStoreMock_Delete_Call { + _c.Call.Return(err) + return _c +} + +func (_c *sessionStoreMock_Delete_Call) RunAndReturn(run func(ctx context.Context, sessionID string) error) *sessionStoreMock_Delete_Call { + _c.Call.Return(run) + return _c +} + +// DeleteBySessionID provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) DeleteBySessionID(ctx context.Context, sessionID string) error { + ret := _mock.Called(ctx, sessionID) + + if len(ret) == 0 { + panic("no return value specified for DeleteBySessionID") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = returnFunc(ctx, sessionID) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// sessionStoreMock_DeleteBySessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteBySessionID' +type sessionStoreMock_DeleteBySessionID_Call struct { + *mock.Call +} + +// DeleteBySessionID is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +func (_e *sessionStoreMock_Expecter) DeleteBySessionID(ctx interface{}, sessionID interface{}) *sessionStoreMock_DeleteBySessionID_Call { + return &sessionStoreMock_DeleteBySessionID_Call{Call: _e.mock.On("DeleteBySessionID", ctx, sessionID)} +} + +func (_c *sessionStoreMock_DeleteBySessionID_Call) Run(run func(ctx context.Context, sessionID string)) *sessionStoreMock_DeleteBySessionID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_DeleteBySessionID_Call) Return(err error) *sessionStoreMock_DeleteBySessionID_Call { + _c.Call.Return(err) + return _c +} + +func (_c *sessionStoreMock_DeleteBySessionID_Call) RunAndReturn(run func(ctx context.Context, sessionID string) error) *sessionStoreMock_DeleteBySessionID_Call { + _c.Call.Return(run) + return _c +} + +// GetByCheckpoint provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) GetByCheckpoint(ctx context.Context, sessionID string, checkpointID string) (*SessionContext, error) { + ret := _mock.Called(ctx, sessionID, checkpointID) + + if len(ret) == 0 { + panic("no return value specified for GetByCheckpoint") + } + + var r0 *SessionContext + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*SessionContext, error)); ok { + return returnFunc(ctx, sessionID, checkpointID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *SessionContext); ok { + r0 = returnFunc(ctx, sessionID, checkpointID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*SessionContext) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, sessionID, checkpointID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// sessionStoreMock_GetByCheckpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByCheckpoint' +type sessionStoreMock_GetByCheckpoint_Call struct { + *mock.Call +} + +// GetByCheckpoint is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +// - checkpointID string +func (_e *sessionStoreMock_Expecter) GetByCheckpoint(ctx interface{}, sessionID interface{}, checkpointID interface{}) *sessionStoreMock_GetByCheckpoint_Call { + return &sessionStoreMock_GetByCheckpoint_Call{Call: _e.mock.On("GetByCheckpoint", ctx, sessionID, checkpointID)} +} + +func (_c *sessionStoreMock_GetByCheckpoint_Call) Run(run func(ctx context.Context, sessionID string, checkpointID string)) *sessionStoreMock_GetByCheckpoint_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *sessionStoreMock_GetByCheckpoint_Call) Return(sessionContext *SessionContext, err error) *sessionStoreMock_GetByCheckpoint_Call { + _c.Call.Return(sessionContext, err) + return _c +} + +func (_c *sessionStoreMock_GetByCheckpoint_Call) RunAndReturn(run func(ctx context.Context, sessionID string, checkpointID string) (*SessionContext, error)) *sessionStoreMock_GetByCheckpoint_Call { + _c.Call.Return(run) + return _c +} + +// GetByExecutionID provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) GetByExecutionID(ctx context.Context, flowExecutionID string) (*Session, error) { + ret := _mock.Called(ctx, flowExecutionID) + + if len(ret) == 0 { + panic("no return value specified for GetByExecutionID") + } + + var r0 *Session + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*Session, error)); ok { + return returnFunc(ctx, flowExecutionID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *Session); ok { + r0 = returnFunc(ctx, flowExecutionID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, flowExecutionID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// sessionStoreMock_GetByExecutionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByExecutionID' +type sessionStoreMock_GetByExecutionID_Call struct { + *mock.Call +} + +// GetByExecutionID is a helper method to define mock.On call +// - ctx context.Context +// - flowExecutionID string +func (_e *sessionStoreMock_Expecter) GetByExecutionID(ctx interface{}, flowExecutionID interface{}) *sessionStoreMock_GetByExecutionID_Call { + return &sessionStoreMock_GetByExecutionID_Call{Call: _e.mock.On("GetByExecutionID", ctx, flowExecutionID)} +} + +func (_c *sessionStoreMock_GetByExecutionID_Call) Run(run func(ctx context.Context, flowExecutionID string)) *sessionStoreMock_GetByExecutionID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_GetByExecutionID_Call) Return(session *Session, err error) *sessionStoreMock_GetByExecutionID_Call { + _c.Call.Return(session, err) + return _c +} + +func (_c *sessionStoreMock_GetByExecutionID_Call) RunAndReturn(run func(ctx context.Context, flowExecutionID string) (*Session, error)) *sessionStoreMock_GetByExecutionID_Call { + _c.Call.Return(run) + return _c +} + +// GetByHandle provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) GetByHandle(ctx context.Context, handleID string) (*Session, error) { + ret := _mock.Called(ctx, handleID) + + if len(ret) == 0 { + panic("no return value specified for GetByHandle") + } + + var r0 *Session + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*Session, error)); ok { + return returnFunc(ctx, handleID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *Session); ok { + r0 = returnFunc(ctx, handleID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, handleID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// sessionStoreMock_GetByHandle_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetByHandle' +type sessionStoreMock_GetByHandle_Call struct { + *mock.Call +} + +// GetByHandle is a helper method to define mock.On call +// - ctx context.Context +// - handleID string +func (_e *sessionStoreMock_Expecter) GetByHandle(ctx interface{}, handleID interface{}) *sessionStoreMock_GetByHandle_Call { + return &sessionStoreMock_GetByHandle_Call{Call: _e.mock.On("GetByHandle", ctx, handleID)} +} + +func (_c *sessionStoreMock_GetByHandle_Call) Run(run func(ctx context.Context, handleID string)) *sessionStoreMock_GetByHandle_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_GetByHandle_Call) Return(session *Session, err error) *sessionStoreMock_GetByHandle_Call { + _c.Call.Return(session, err) + return _c +} + +func (_c *sessionStoreMock_GetByHandle_Call) RunAndReturn(run func(ctx context.Context, handleID string) (*Session, error)) *sessionStoreMock_GetByHandle_Call { + _c.Call.Return(run) + return _c +} + +// ListBySessionID provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) ListBySessionID(ctx context.Context, sessionID string) ([]Participant, error) { + ret := _mock.Called(ctx, sessionID) + + if len(ret) == 0 { + panic("no return value specified for ListBySessionID") + } + + var r0 []Participant + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]Participant, error)); ok { + return returnFunc(ctx, sessionID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) []Participant); ok { + r0 = returnFunc(ctx, sessionID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]Participant) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, sessionID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// sessionStoreMock_ListBySessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListBySessionID' +type sessionStoreMock_ListBySessionID_Call struct { + *mock.Call +} + +// ListBySessionID is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +func (_e *sessionStoreMock_Expecter) ListBySessionID(ctx interface{}, sessionID interface{}) *sessionStoreMock_ListBySessionID_Call { + return &sessionStoreMock_ListBySessionID_Call{Call: _e.mock.On("ListBySessionID", ctx, sessionID)} +} + +func (_c *sessionStoreMock_ListBySessionID_Call) Run(run func(ctx context.Context, sessionID string)) *sessionStoreMock_ListBySessionID_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_ListBySessionID_Call) Return(participants []Participant, err error) *sessionStoreMock_ListBySessionID_Call { + _c.Call.Return(participants, err) + return _c +} + +func (_c *sessionStoreMock_ListBySessionID_Call) RunAndReturn(run func(ctx context.Context, sessionID string) ([]Participant, error)) *sessionStoreMock_ListBySessionID_Call { + _c.Call.Return(run) + return _c +} + +// ListCheckpointIDs provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) ListCheckpointIDs(ctx context.Context, sessionID string) ([]string, error) { + ret := _mock.Called(ctx, sessionID) + + if len(ret) == 0 { + panic("no return value specified for ListCheckpointIDs") + } + + var r0 []string + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]string, error)); ok { + return returnFunc(ctx, sessionID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) []string); ok { + r0 = returnFunc(ctx, sessionID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, sessionID) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// sessionStoreMock_ListCheckpointIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListCheckpointIDs' +type sessionStoreMock_ListCheckpointIDs_Call struct { + *mock.Call +} + +// ListCheckpointIDs is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +func (_e *sessionStoreMock_Expecter) ListCheckpointIDs(ctx interface{}, sessionID interface{}) *sessionStoreMock_ListCheckpointIDs_Call { + return &sessionStoreMock_ListCheckpointIDs_Call{Call: _e.mock.On("ListCheckpointIDs", ctx, sessionID)} +} + +func (_c *sessionStoreMock_ListCheckpointIDs_Call) Run(run func(ctx context.Context, sessionID string)) *sessionStoreMock_ListCheckpointIDs_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_ListCheckpointIDs_Call) Return(strings []string, err error) *sessionStoreMock_ListCheckpointIDs_Call { + _c.Call.Return(strings, err) + return _c +} + +func (_c *sessionStoreMock_ListCheckpointIDs_Call) RunAndReturn(run func(ctx context.Context, sessionID string) ([]string, error)) *sessionStoreMock_ListCheckpointIDs_Call { + _c.Call.Return(run) + return _c +} + +// Record provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) Record(ctx context.Context, p Participant) error { + ret := _mock.Called(ctx, p) + + if len(ret) == 0 { + panic("no return value specified for Record") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, Participant) error); ok { + r0 = returnFunc(ctx, p) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// sessionStoreMock_Record_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Record' +type sessionStoreMock_Record_Call struct { + *mock.Call +} + +// Record is a helper method to define mock.On call +// - ctx context.Context +// - p Participant +func (_e *sessionStoreMock_Expecter) Record(ctx interface{}, p interface{}) *sessionStoreMock_Record_Call { + return &sessionStoreMock_Record_Call{Call: _e.mock.On("Record", ctx, p)} +} + +func (_c *sessionStoreMock_Record_Call) Run(run func(ctx context.Context, p Participant)) *sessionStoreMock_Record_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 Participant + if args[1] != nil { + arg1 = args[1].(Participant) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_Record_Call) Return(err error) *sessionStoreMock_Record_Call { + _c.Call.Return(err) + return _c +} + +func (_c *sessionStoreMock_Record_Call) RunAndReturn(run func(ctx context.Context, p Participant) error) *sessionStoreMock_Record_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function for the type sessionStoreMock +func (_mock *sessionStoreMock) Update(ctx context.Context, s *Session) error { + ret := _mock.Called(ctx, s) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(context.Context, *Session) error); ok { + r0 = returnFunc(ctx, s) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// sessionStoreMock_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type sessionStoreMock_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - s *Session +func (_e *sessionStoreMock_Expecter) Update(ctx interface{}, s interface{}) *sessionStoreMock_Update_Call { + return &sessionStoreMock_Update_Call{Call: _e.mock.On("Update", ctx, s)} +} + +func (_c *sessionStoreMock_Update_Call) Run(run func(ctx context.Context, s *Session)) *sessionStoreMock_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 *Session + if args[1] != nil { + arg1 = args[1].(*Session) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *sessionStoreMock_Update_Call) Return(err error) *sessionStoreMock_Update_Call { + _c.Call.Return(err) + return _c +} + +func (_c *sessionStoreMock_Update_Call) RunAndReturn(run func(ctx context.Context, s *Session) error) *sessionStoreMock_Update_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/internal/flow/session/session_context.go b/backend/internal/flow/session/session_context.go new file mode 100644 index 0000000000..c61b07df67 --- /dev/null +++ b/backend/internal/flow/session/session_context.go @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "encoding/json" + "fmt" +) + +// MaxSessionContextBytes bounds the serialized session context payload, keeping the sibling row +// small and preventing unbounded growth from accumulated step facts and claims. +const MaxSessionContextBytes = 16 * 1024 + +// SessionContext is a durable authenticated-context snapshot of a session at one checkpoint, stored +// in SSO_SESSION_CONTEXT keyed by (session_id, checkpoint_id) — 1:many, one row per checkpoint (join +// node) reached in the flow. It holds the runtime state the SSO skip replays at that join: the flow's +// RuntimeData (which carries the in-flow attribute manipulations and federated claims), the subject +// reference, and the completed steps keyed by node id. It is read only on the SSO load path — never +// touched by activity (last_active_at) updates. +// +// RuntimeData is persisted in full pending the flow-context data-classification revisit. Attributes +// are not materialized here: a local subject's attributes are re-resolved from the entity store via +// the subject reference on load, and a federated subject's authoritative claims are carried in +// RuntimeData. Aggregate facts used by hot-path policy checks (authenticated_at) live on SESSION, +// not here, so those checks never load this context. +type SessionContext struct { + // SessionID is the owning session's internal id. + SessionID string + // CheckpointID identifies the checkpoint (join node) this snapshot belongs to; together with + // SessionID it is the row's key. One session accumulates one snapshot per checkpoint it reaches. + CheckpointID string + // RuntimeData is the flow's runtime data captured at the save node. It is the durable carrier + // of the effective attribute set — in-flow manipulations and federated claims included. + RuntimeData map[string]string + // AuthUser is the marshaled subject reference (resolved entity reference + a re-resolvable + // attribute token), not materialized attributes; on load GetUserAttributes re-resolves the + // subject's attributes fresh from the entity store. + AuthUser json.RawMessage + // CompletedSteps records the completed authentication steps keyed by node id. + CompletedSteps map[string]StepFact + // ContextVersion versions the context payload schema/content independently of the session. + ContextVersion int +} + +// StepFact is a per-node completed authentication-step fact. +type StepFact struct { + Executor string `json:"executor,omitempty"` + Status string `json:"status,omitempty"` + // CompletedAt is the Unix time (seconds) at which the authentication step completed. + CompletedAt int64 `json:"completedAt,omitempty"` +} + +// sessionContextPayload is the JSON form of the session context stored in the CONTEXT column. The +// context version is stored as its own column, not in the payload. +type sessionContextPayload struct { + RuntimeData map[string]string `json:"runtimeData,omitempty"` + AuthUser json.RawMessage `json:"authUser,omitempty"` + CompletedSteps map[string]StepFact `json:"completedSteps,omitempty"` +} + +// serializePayload renders the persistable portion of the session context to JSON. +func (c SessionContext) serializePayload() (string, error) { + data, err := json.Marshal(sessionContextPayload{ + RuntimeData: c.RuntimeData, + AuthUser: c.AuthUser, + CompletedSteps: c.CompletedSteps, + }) + if err != nil { + return "", fmt.Errorf("failed to serialize session context: %w", err) + } + return string(data), nil +} + +// parseSessionContextPayload parses the JSON payload of an session context. +func parseSessionContextPayload(raw string) (sessionContextPayload, error) { + var payload sessionContextPayload + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return sessionContextPayload{}, fmt.Errorf("failed to parse session context: %w", err) + } + return payload, nil +} diff --git a/backend/internal/flow/session/session_context_store.go b/backend/internal/flow/session/session_context_store.go new file mode 100644 index 0000000000..690e80d22d --- /dev/null +++ b/backend/internal/flow/session/session_context_store.go @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "fmt" + + "github.com/thunder-id/thunderid/internal/system/database/provider" +) + +// CreateContext persists the session context. It rejects payloads exceeding MaxSessionContextBytes. +func (st *store) CreateContext(ctx context.Context, c SessionContext) error { + payload, err := c.serializePayload() + if err != nil { + return err + } + if len(payload) > MaxSessionContextBytes { + return errSessionContextTooLarge + } + + return withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + _, execErr := dbClient.ExecuteContext(ctx, queryCreateSessionContext, + c.SessionID, st.deploymentID, c.CheckpointID, payload, c.ContextVersion) + if execErr != nil { + return fmt.Errorf("failed to create session context: %w", execErr) + } + return nil + }) +} + +// GetByCheckpoint fetches one checkpoint's session context. +func (st *store) GetByCheckpoint(ctx context.Context, sessionID, + checkpointID string) (*SessionContext, error) { + var result *SessionContext + + err := withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + results, queryErr := dbClient.QueryContext(ctx, queryGetSessionContextByCheckpoint, + sessionID, st.deploymentID, checkpointID) + if queryErr != nil { + return fmt.Errorf("failed to execute query: %w", queryErr) + } + if len(results) == 0 { + return nil + } + if len(results) != 1 { + return fmt.Errorf("unexpected number of results: %d", len(results)) + } + + c, buildErr := st.buildSessionContextFromRow(results[0]) + if buildErr != nil { + return buildErr + } + result = c + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// ListCheckpointIDs returns the checkpoint ids a session has saved, without decrypting any payload. +func (st *store) ListCheckpointIDs(ctx context.Context, sessionID string) ([]string, error) { + var ids []string + + err := withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + results, queryErr := dbClient.QueryContext(ctx, queryListCheckpointsBySessionID, sessionID, st.deploymentID) + if queryErr != nil { + return fmt.Errorf("failed to execute query: %w", queryErr) + } + for _, row := range results { + id, parseErr := parseString(row["checkpoint_id"], "checkpoint_id") + if parseErr != nil { + return parseErr + } + ids = append(ids, id) + } + return nil + }) + if err != nil { + return nil, err + } + return ids, nil +} + +// Delete removes a session's session context. +func (st *store) Delete(ctx context.Context, sessionID string) error { + return withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + _, err := dbClient.ExecuteContext(ctx, queryDeleteSessionContext, sessionID, st.deploymentID) + if err != nil { + return fmt.Errorf("failed to delete session context: %w", err) + } + return nil + }) +} + +// buildSessionContextFromRow parses a result row into an SessionContext. +func (st *store) buildSessionContextFromRow(row map[string]interface{}) (*SessionContext, error) { + sessionID, err := parseString(row["session_id"], "session_id") + if err != nil { + return nil, err + } + checkpointID, err := parseString(row["checkpoint_id"], "checkpoint_id") + if err != nil { + return nil, err + } + contextVersion, err := parseInt(row["context_version"], "context_version") + if err != nil { + return nil, err + } + payload, err := parseSessionContextPayload(parseNullableString(row["context"])) + if err != nil { + return nil, err + } + + return &SessionContext{ + SessionID: sessionID, + CheckpointID: checkpointID, + RuntimeData: payload.RuntimeData, + AuthUser: payload.AuthUser, + CompletedSteps: payload.CompletedSteps, + ContextVersion: contextVersion, + }, nil +} diff --git a/backend/internal/flow/session/session_context_store_test.go b/backend/internal/flow/session/session_context_store_test.go new file mode 100644 index 0000000000..684b2f3efb --- /dev/null +++ b/backend/internal/flow/session/session_context_store_test.go @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/tests/mocks/database/providermock" +) + +type SessionContextStoreTestSuite struct { + suite.Suite + mockDBProvider *providermock.DBProviderInterfaceMock + mockDBClient *providermock.DBClientInterfaceMock + store *store +} + +func TestSessionContextStoreTestSuite(t *testing.T) { + suite.Run(t, new(SessionContextStoreTestSuite)) +} + +func (s *SessionContextStoreTestSuite) SetupTest() { + s.mockDBProvider = &providermock.DBProviderInterfaceMock{} + s.mockDBClient = &providermock.DBClientInterfaceMock{} + s.store = &store{ + dbProvider: s.mockDBProvider, + deploymentID: testDeploymentID, + } +} + +func sampleSessionContext() SessionContext { + return SessionContext{ + SessionID: "sess-1", + CheckpointID: "session", + RuntimeData: map[string]string{"email": "alice@example.com"}, + AuthUser: json.RawMessage(`{"entityReference":{"entityId":"user-1"}}`), + CompletedSteps: map[string]StepFact{"basic_auth": {Executor: "BasicAuthExecutor", Status: "COMPLETE"}}, + ContextVersion: 1, + } +} + +func (s *SessionContextStoreTestSuite) TestCreate_Persists() { + c := sampleSessionContext() + payload, err := c.serializePayload() + s.Require().NoError(err) + + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryCreateSessionContext, + c.SessionID, testDeploymentID, c.CheckpointID, payload, c.ContextVersion). + Return(int64(1), nil) + + createErr := s.store.CreateContext(context.Background(), c) + + s.NoError(createErr) + s.mockDBClient.AssertExpectations(s.T()) +} + +func (s *SessionContextStoreTestSuite) TestCreate_TooLarge() { + c := sampleSessionContext() + c.RuntimeData = map[string]string{"email": strings.Repeat("a", MaxSessionContextBytes+1)} + + createErr := s.store.CreateContext(context.Background(), c) + + s.ErrorIs(createErr, errSessionContextTooLarge) + // Oversized payloads are rejected before any DB call. + s.mockDBProvider.AssertNotCalled(s.T(), "GetOperationDBClient") +} + +func (s *SessionContextStoreTestSuite) TestCreate_DBError() { + c := sampleSessionContext() + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryCreateSessionContext, + c.SessionID, testDeploymentID, c.CheckpointID, mustSerialize(s.T(), c), c.ContextVersion). + Return(int64(0), errors.New("db down")) + + createErr := s.store.CreateContext(context.Background(), c) + + s.Error(createErr) + s.Contains(createErr.Error(), "failed to create session context") +} + +func (s *SessionContextStoreTestSuite) TestGetByCheckpoint_Hit() { + c := sampleSessionContext() + payload, err := c.serializePayload() + s.Require().NoError(err) + + row := map[string]interface{}{ + "session_id": "sess-1", + "checkpoint_id": "session", + "context": payload, + "context_version": int64(1), + } + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionContextByCheckpoint, + "sess-1", testDeploymentID, "session"). + Return([]map[string]interface{}{row}, nil) + + got, getErr := s.store.GetByCheckpoint(context.Background(), "sess-1", "session") + + s.NoError(getErr) + s.Require().NotNil(got) + s.Equal("sess-1", got.SessionID) + s.Equal("session", got.CheckpointID) + s.Equal(1, got.ContextVersion) + s.Equal("alice@example.com", got.RuntimeData["email"]) + s.JSONEq(`{"entityReference":{"entityId":"user-1"}}`, string(got.AuthUser)) + s.Equal("BasicAuthExecutor", got.CompletedSteps["basic_auth"].Executor) +} + +func (s *SessionContextStoreTestSuite) TestGetByCheckpoint_Miss() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionContextByCheckpoint, + "sess-1", testDeploymentID, "missing"). + Return([]map[string]interface{}{}, nil) + + got, getErr := s.store.GetByCheckpoint(context.Background(), "sess-1", "missing") + + s.NoError(getErr) + s.Nil(got) +} + +func (s *SessionContextStoreTestSuite) TestListCheckpointIDs() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryListCheckpointsBySessionID, + "sess-1", testDeploymentID). + Return([]map[string]interface{}{ + {"checkpoint_id": "password"}, + {"checkpoint_id": "step_up"}, + }, nil) + + ids, listErr := s.store.ListCheckpointIDs(context.Background(), "sess-1") + + s.NoError(listErr) + s.Equal([]string{"password", "step_up"}, ids) +} + +func (s *SessionContextStoreTestSuite) TestDelete() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryDeleteSessionContext, + "sess-1", testDeploymentID). + Return(int64(1), nil) + + delErr := s.store.Delete(context.Background(), "sess-1") + + s.NoError(delErr) + s.mockDBClient.AssertExpectations(s.T()) +} + +func (s *SessionContextStoreTestSuite) TestGetByCheckpoint_QueryError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionContextByCheckpoint, + "sess-1", testDeploymentID, "session"). + Return(nil, errors.New("query failed")) + + got, err := s.store.GetByCheckpoint(context.Background(), "sess-1", "session") + s.Error(err) + s.Nil(got) +} + +func (s *SessionContextStoreTestSuite) TestGetByCheckpoint_MultipleRows() { + row := map[string]interface{}{ + "session_id": "sess-1", "checkpoint_id": "session", + "context": "{}", "context_version": int64(1), + } + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionContextByCheckpoint, + "sess-1", testDeploymentID, "session"). + Return([]map[string]interface{}{row, row}, nil) + + got, err := s.store.GetByCheckpoint(context.Background(), "sess-1", "session") + s.Error(err) + s.Nil(got) + s.Contains(err.Error(), "unexpected number of results") +} + +func (s *SessionContextStoreTestSuite) TestGetByCheckpoint_BuildError() { + row := map[string]interface{}{"session_id": 42, "checkpoint_id": "session", "context_version": int64(1)} + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionContextByCheckpoint, + "sess-1", testDeploymentID, "session"). + Return([]map[string]interface{}{row}, nil) + + got, err := s.store.GetByCheckpoint(context.Background(), "sess-1", "session") + s.Error(err) + s.Nil(got) +} + +func (s *SessionContextStoreTestSuite) TestGetByCheckpoint_BadContextVersion() { + row := map[string]interface{}{ + "session_id": "sess-1", "checkpoint_id": "session", + "context": "{}", "context_version": "nope", + } + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionContextByCheckpoint, + "sess-1", testDeploymentID, "session"). + Return([]map[string]interface{}{row}, nil) + + got, err := s.store.GetByCheckpoint(context.Background(), "sess-1", "session") + s.Error(err) + s.Nil(got) +} + +func (s *SessionContextStoreTestSuite) TestGetByCheckpoint_BadPayload() { + // A context column that is not valid JSON fails to parse. + row := map[string]interface{}{ + "session_id": "sess-1", "checkpoint_id": "session", + "context": "not-json", "context_version": int64(1), + } + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionContextByCheckpoint, + "sess-1", testDeploymentID, "session"). + Return([]map[string]interface{}{row}, nil) + + got, err := s.store.GetByCheckpoint(context.Background(), "sess-1", "session") + s.Error(err) + s.Nil(got) +} + +func (s *SessionContextStoreTestSuite) TestListCheckpointIDs_QueryError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryListCheckpointsBySessionID, + "sess-1", testDeploymentID). + Return(nil, errors.New("query failed")) + + got, err := s.store.ListCheckpointIDs(context.Background(), "sess-1") + s.Error(err) + s.Nil(got) +} + +func (s *SessionContextStoreTestSuite) TestListCheckpointIDs_ParseError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryListCheckpointsBySessionID, + "sess-1", testDeploymentID). + Return([]map[string]interface{}{{"checkpoint_id": 42}}, nil) // non-string fails parseString + + got, err := s.store.ListCheckpointIDs(context.Background(), "sess-1") + s.Error(err) + s.Nil(got) +} + +func (s *SessionContextStoreTestSuite) TestDelete_DBError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryDeleteSessionContext, + "sess-1", testDeploymentID). + Return(int64(0), errors.New("db down")) + + err := s.store.Delete(context.Background(), "sess-1") + s.Error(err) + s.Contains(err.Error(), "failed to delete session context") +} + +func mustSerialize(t *testing.T, c SessionContext) string { + t.Helper() + payload, err := c.serializePayload() + if err != nil { + t.Fatalf("serialize payload: %v", err) + } + return payload +} diff --git a/backend/internal/flow/session/state.go b/backend/internal/flow/session/state.go new file mode 100644 index 0000000000..228fbd63d5 --- /dev/null +++ b/backend/internal/flow/session/state.go @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "time" +) + +// DefaultIdleTimeout is the maximum inactivity period before a session expires. The idle deadline +// slides forward on each activity touch. +const DefaultIdleTimeout = 30 * time.Minute + +// DefaultAbsoluteTimeout is the maximum lifetime of a session regardless of activity. It is fixed +// at creation and never extended. It also bounds the transport cookie's max-age. +const DefaultAbsoluteTimeout = 8 * time.Hour + +// Timeouts holds the resolved session lifetime durations used when minting and refreshing sessions. +type Timeouts struct { + // Idle is the maximum inactivity period; the idle deadline slides on each activity touch. + Idle time.Duration + // Absolute is the maximum lifetime of a session regardless of activity. + Absolute time.Duration +} + +// DefaultTimeouts returns the built-in default session timeouts. +func DefaultTimeouts() Timeouts { + return Timeouts{ + Idle: DefaultIdleTimeout, + Absolute: DefaultAbsoluteTimeout, + } +} + +// NewTimeouts builds session timeouts from per-field second values, falling back to the built-in +// default for any non-positive value. The idle window is clamped to the absolute lifetime so the +// pair is always valid — a defaulted idle can otherwise outrun a small configured absolute, which +// SessionConfig validation does not catch (it only compares the raw, positive config values). +func NewTimeouts(idleSeconds, absoluteSeconds int64) Timeouts { + t := DefaultTimeouts() + if idleSeconds > 0 { + t.Idle = time.Duration(idleSeconds) * time.Second + } + if absoluteSeconds > 0 { + t.Absolute = time.Duration(absoluteSeconds) * time.Second + } + if t.Idle > t.Absolute { + t.Idle = t.Absolute + } + return t +} diff --git a/backend/internal/flow/session/state_test.go b/backend/internal/flow/session/state_test.go new file mode 100644 index 0000000000..2a5223b1e7 --- /dev/null +++ b/backend/internal/flow/session/state_test.go @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type StateTestSuite struct { + suite.Suite +} + +func TestStateTestSuite(t *testing.T) { + suite.Run(t, new(StateTestSuite)) +} + +func (s *StateTestSuite) TestNewTimeouts_Defaults() { + // Non-positive values fall back to the built-in defaults. + got := NewTimeouts(-5, 0) + s.Equal(DefaultTimeouts(), got) +} + +func (s *StateTestSuite) TestNewTimeouts_Overrides() { + got := NewTimeouts(60, 600) + s.Equal(60*time.Second, got.Idle) + s.Equal(600*time.Second, got.Absolute) +} + +func (s *StateTestSuite) TestNewTimeouts_PartialOverride() { + got := NewTimeouts(60, 0) + s.Equal(60*time.Second, got.Idle) + s.Equal(DefaultAbsoluteTimeout, got.Absolute, "unset absolute falls back to default") +} + +func (s *StateTestSuite) TestNewTimeouts_IdleClampedToAbsolute() { + // A small configured absolute with a defaulted (larger) idle must not yield idle > absolute. + got := NewTimeouts(0, 60) + s.Equal(60*time.Second, got.Absolute) + s.Equal(60*time.Second, got.Idle, "idle is clamped to the absolute lifetime") + + // An explicit idle larger than absolute is likewise clamped. + got = NewTimeouts(600, 300) + s.Equal(300*time.Second, got.Idle) + s.Equal(300*time.Second, got.Absolute) +} + +func (s *StateTestSuite) TestSessionContext_PayloadRoundTrip() { + c := SessionContext{ + SessionID: "sess-1", + RuntimeData: map[string]string{"email": "a@b.com", "department": "eng"}, + AuthUser: json.RawMessage(`{"entityReference":{"entityId":"user-1"}}`), + CompletedSteps: map[string]StepFact{"basic_auth": {Executor: "BasicAuthExecutor", Status: "COMPLETE"}}, + ContextVersion: 1, + } + + raw, err := c.serializePayload() + s.Require().NoError(err) + + parsed, err := parseSessionContextPayload(raw) + s.Require().NoError(err) + s.Equal(c.RuntimeData, parsed.RuntimeData) + s.JSONEq(string(c.AuthUser), string(parsed.AuthUser)) + s.Equal(c.CompletedSteps, parsed.CompletedSteps) +} + +func (s *StateTestSuite) TestParseSessionContextPayload_Invalid() { + _, err := parseSessionContextPayload("{not json") + s.Error(err) +} + +// TestSessionRowCarriesNoTransientExecutionState guards the lean, hot-path SESSION row: transient +// per-execution state (current node, partial inputs, runtime data, execution history, challenge +// token) must never land on it, so liveness/activity checks never pull execution state. +// +// The session-context sibling intentionally snapshots runtime state (RuntimeData, the resolved +// AuthUser, completed steps) so the SSO join can replay the effective attribute set — so this +// guard deliberately does NOT cover SessionContext. +func (s *StateTestSuite) TestSessionRowCarriesNoTransientExecutionState() { + forbidden := []string{ + "runtimedata", + "userinput", + "currentnode", + "currentaction", + "currentsegment", + "executionhistory", + "challengetoken", + "forwardeddata", + "partialinput", + "flowstate", + } + + types := []reflect.Type{ + reflect.TypeOf(Session{}), + } + + for _, typ := range types { + for i := 0; i < typ.NumField(); i++ { + name := strings.ToLower(typ.Field(i).Name) + for _, f := range forbidden { + s.NotContains(name, f, + "%s.%s looks like transient execution state; it belongs in the flow store, not the session", + typ.Name(), typ.Field(i).Name) + } + } + } +} diff --git a/backend/internal/flow/session/store.go b/backend/internal/flow/session/store.go new file mode 100644 index 0000000000..7d1c529882 --- /dev/null +++ b/backend/internal/flow/session/store.go @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "fmt" + "time" + + "github.com/thunder-id/thunderid/internal/system/database/model" + "github.com/thunder-id/thunderid/internal/system/database/provider" + sysutils "github.com/thunder-id/thunderid/internal/system/utils" +) + +// store is the single operation-DB-backed persistence implementation for SSO sessions. It satisfies +// the sessionStore interface — session rows, per-checkpoint contexts, and participants all back onto +// the same operation datasource, so they share one struct rather than duplicating the provider and +// deployment id across parallel stores. +type store struct { + dbProvider provider.DBProviderInterface + deploymentID string +} + +// newStore creates the session store backed by the given operation DB provider. It returns the +// concrete type so Initialize can hand the one instance to each of the store interfaces the service +// depends on. +func newStore(dbProvider provider.DBProviderInterface, deploymentID string) *store { + return &store{ + dbProvider: dbProvider, + deploymentID: deploymentID, + } +} + +// Create persists a new session. +func (st *store) Create(ctx context.Context, s Session) error { + return withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + _, err := dbClient.ExecuteContext(ctx, queryCreateSession, + s.SessionID, st.deploymentID, s.SubjectID, s.FlowID, s.FlowVersion, + s.FlowExecutionID, s.HandleID, + s.AuthenticatedAt, s.CreatedAt, s.LastActiveAt, + nullableTime(s.IdleExpiresAt), nullableTime(s.AbsoluteExpiresAt), string(s.State), s.Version) + if err != nil { + return fmt.Errorf("failed to create session: %w", err) + } + return nil + }) +} + +// GetByHandle fetches a session by its opaque handle ID. +func (st *store) GetByHandle(ctx context.Context, handleID string) (*Session, error) { + return st.getSingle(ctx, queryGetSessionByHandle, handleID) +} + +// GetByExecutionID fetches the session established by the given flow execution. +func (st *store) GetByExecutionID(ctx context.Context, flowExecutionID string) (*Session, error) { + return st.getSingle(ctx, queryGetSessionByExecutionID, flowExecutionID) +} + +// getSingle runs a single-key lookup query and maps the at-most-one row into a Session, returning +// (nil, nil) when no row matches. +func (st *store) getSingle(ctx context.Context, query model.DBQuery, key string) (*Session, error) { + var result *Session + + err := withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + results, err := dbClient.QueryContext(ctx, query, key, st.deploymentID) + if err != nil { + return fmt.Errorf("failed to execute query: %w", err) + } + if len(results) == 0 { + return nil + } + if len(results) != 1 { + return fmt.Errorf("unexpected number of results: %d", len(results)) + } + + s, buildErr := buildSessionFromRow(results[0]) + if buildErr != nil { + return buildErr + } + result = s + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// Update writes the mutable fields of an existing session under an optimistic-lock guard. It +// touches only SESSION — never the auth context — so an activity touch stays lean. +func (st *store) Update(ctx context.Context, s *Session) error { + return withOperationDBClient(st.dbProvider, func(dbClient provider.DBClientInterface) error { + rowsAffected, err := dbClient.ExecuteContext(ctx, queryUpdateSession, + s.FlowVersion, s.HandleID, + s.LastActiveAt, + nullableTime(s.IdleExpiresAt), nullableTime(s.AbsoluteExpiresAt), string(s.State), + s.SessionID, st.deploymentID, s.Version) + if err != nil { + return fmt.Errorf("failed to update session: %w", err) + } + if rowsAffected == 0 { + return errVersionConflict + } + s.Version++ + return nil + }) +} + +// withOperationDBClient runs fn with an operation database client. SSO sessions are persistent +// state that must survive a runtime database flush, so they live in the operation datasource. +func withOperationDBClient(dbProvider provider.DBProviderInterface, + fn func(provider.DBClientInterface) error) error { + dbClient, err := dbProvider.GetOperationDBClient() + if err != nil { + return fmt.Errorf("failed to get database client: %w", err) + } + return fn(dbClient) +} + +// nullableTime returns nil for a zero time so nullable columns store NULL, otherwise the time. +func nullableTime(t time.Time) interface{} { + if t.IsZero() { + return nil + } + return t +} + +// buildSessionFromRow maps a database result row into a Session. +func buildSessionFromRow(row map[string]interface{}) (*Session, error) { + sessionID, err := parseString(row["session_id"], "session_id") + if err != nil { + return nil, err + } + subjectID, err := parseString(row["subject_id"], "subject_id") + if err != nil { + return nil, err + } + flowID, err := parseString(row["flow_id"], "flow_id") + if err != nil { + return nil, err + } + flowVersion, err := parseInt(row["flow_version"], "flow_version") + if err != nil { + return nil, err + } + flowExecutionID, err := parseString(row["flow_execution_id"], "flow_execution_id") + if err != nil { + return nil, err + } + handleID, err := parseString(row["handle_id"], "handle_id") + if err != nil { + return nil, err + } + authenticatedAt, err := sysutils.ParseDBTimeField(row["authenticated_at"], "authenticated_at") + if err != nil { + return nil, err + } + createdAt, err := sysutils.ParseDBTimeField(row["created_at"], "created_at") + if err != nil { + return nil, err + } + lastActiveAt, err := sysutils.ParseDBTimeField(row["last_active_at"], "last_active_at") + if err != nil { + return nil, err + } + version, err := parseInt(row["version"], "version") + if err != nil { + return nil, err + } + + return &Session{ + SessionID: sessionID, + SubjectID: subjectID, + FlowID: flowID, + FlowVersion: flowVersion, + FlowExecutionID: flowExecutionID, + HandleID: handleID, + AuthenticatedAt: authenticatedAt, + CreatedAt: createdAt, + LastActiveAt: lastActiveAt, + IdleExpiresAt: parseNullableTime(row["idle_expires_at"]), + AbsoluteExpiresAt: parseNullableTime(row["absolute_expires_at"]), + State: State(parseNullableString(row["state"])), + Version: version, + }, nil +} + +// parseString parses a required string column. +func parseString(value interface{}, field string) (string, error) { + if s := parseNullableStringPtr(value); s != nil { + return *s, nil + } + return "", fmt.Errorf("failed to parse %s as string", field) +} + +// parseNullableString parses an optional string column, returning "" when null. +func parseNullableString(value interface{}) string { + if s := parseNullableStringPtr(value); s != nil { + return *s + } + return "" +} + +// parseNullableStringPtr parses a string column, handling the []byte form some drivers return. +func parseNullableStringPtr(value interface{}) *string { + switch v := value.(type) { + case string: + return &v + case []byte: + s := string(v) + return &s + default: + return nil + } +} + +// parseInt parses an integer column across the numeric forms drivers may return. +func parseInt(value interface{}, field string) (int, error) { + switch v := value.(type) { + case int: + return v, nil + case int32: + return int(v), nil + case int64: + return int(v), nil + case float64: + return int(v), nil + default: + return 0, fmt.Errorf("failed to parse %s as int: got %T", field, value) + } +} + +// parseNullableTime parses an optional time column, returning the zero time when null or +// unparseable. +func parseNullableTime(value interface{}) time.Time { + if value == nil { + return time.Time{} + } + t, err := sysutils.ParseDBTimeField(value, "") + if err != nil { + return time.Time{} + } + return t +} diff --git a/backend/internal/flow/session/store_constants.go b/backend/internal/flow/session/store_constants.go new file mode 100644 index 0000000000..3290d51f1a --- /dev/null +++ b/backend/internal/flow/session/store_constants.go @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "github.com/thunder-id/thunderid/internal/system/database/model" +) + +var ( + // queryCreateSession inserts a new SSO session. It is idempotent per establishing flow execution: + // on a FLOW_EXECUTION_ID conflict it does nothing, so concurrent joins in one execution converge + // on the single session that won the race (the caller re-reads it via queryGetSessionByExecutionID). + // The ON CONFLICT ... DO NOTHING form is valid in both PostgreSQL and SQLite. + queryCreateSession = model.DBQuery{ + ID: "SSO-SESS-01", + Query: `INSERT INTO "SSO_SESSION" (SESSION_ID, DEPLOYMENT_ID, SUBJECT_ID, FLOW_ID, FLOW_VERSION, ` + + `FLOW_EXECUTION_ID, HANDLE_ID, ` + + `AUTHENTICATED_AT, CREATED_AT, LAST_ACTIVE_AT, IDLE_EXPIRES_AT, ABSOLUTE_EXPIRES_AT, STATE, VERSION) ` + + `VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) ` + + `ON CONFLICT (FLOW_EXECUTION_ID, DEPLOYMENT_ID) DO NOTHING`, + } + + // queryGetSessionByHandle fetches a session by its opaque handle ID. Liveness checks + // (state, deadlines) are applied by the resolver, not here. + queryGetSessionByHandle = model.DBQuery{ + ID: "SSO-SESS-02", + Query: `SELECT SESSION_ID, SUBJECT_ID, FLOW_ID, FLOW_VERSION, FLOW_EXECUTION_ID, HANDLE_ID, ` + + `AUTHENTICATED_AT, CREATED_AT, LAST_ACTIVE_AT, IDLE_EXPIRES_AT, ABSOLUTE_EXPIRES_AT, STATE, VERSION ` + + `FROM "SSO_SESSION" WHERE HANDLE_ID = $1 AND DEPLOYMENT_ID = $2`, + } + + // queryGetSessionByExecutionID fetches the session established by a given flow execution, or no + // rows when that execution has not established one. Used on the fresh join path to attach later + // checkpoints to the session an earlier join in the same execution already created. + queryGetSessionByExecutionID = model.DBQuery{ + ID: "SSO-SESS-04", + Query: `SELECT SESSION_ID, SUBJECT_ID, FLOW_ID, FLOW_VERSION, FLOW_EXECUTION_ID, HANDLE_ID, ` + + `AUTHENTICATED_AT, CREATED_AT, LAST_ACTIVE_AT, IDLE_EXPIRES_AT, ABSOLUTE_EXPIRES_AT, STATE, VERSION ` + + `FROM "SSO_SESSION" WHERE FLOW_EXECUTION_ID = $1 AND DEPLOYMENT_ID = $2`, + } + + // queryUpdateSession updates the mutable fields of a session under an optimistic-lock + // guard: it only matches when the stored VERSION equals the expected version, and it + // bumps VERSION on success. It never touches the session context. + queryUpdateSession = model.DBQuery{ + ID: "SSO-SESS-03", + Query: `UPDATE "SSO_SESSION" SET FLOW_VERSION = $1, HANDLE_ID = $2, ` + + `LAST_ACTIVE_AT = $3, IDLE_EXPIRES_AT = $4, ABSOLUTE_EXPIRES_AT = $5, STATE = $6, ` + + `VERSION = VERSION + 1, UPDATED_AT = CURRENT_TIMESTAMP ` + + `WHERE SESSION_ID = $7 AND DEPLOYMENT_ID = $8 AND VERSION = $9`, + } + + // queryCreateSessionContext upserts a checkpoint's session context. Re-saving the same checkpoint + // (re-execution or a concurrent request) overwrites it rather than erroring on the primary key. + // The ON CONFLICT ... DO UPDATE form is valid in both PostgreSQL and SQLite. + queryCreateSessionContext = model.DBQuery{ + ID: "SSO-SESS-AC-01", + Query: `INSERT INTO "SSO_SESSION_CONTEXT" (SESSION_ID, DEPLOYMENT_ID, CHECKPOINT_ID, CONTEXT, ` + + `CONTEXT_VERSION) VALUES ($1, $2, $3, $4, $5) ` + + `ON CONFLICT (SESSION_ID, DEPLOYMENT_ID, CHECKPOINT_ID) DO UPDATE SET ` + + `CONTEXT = excluded.CONTEXT, CONTEXT_VERSION = excluded.CONTEXT_VERSION`, + } + + // queryGetSessionContextByCheckpoint fetches one checkpoint's session context for a session. + queryGetSessionContextByCheckpoint = model.DBQuery{ + ID: "SSO-SESS-AC-02", + Query: `SELECT SESSION_ID, CHECKPOINT_ID, CONTEXT, CONTEXT_VERSION FROM "SSO_SESSION_CONTEXT" ` + + `WHERE SESSION_ID = $1 AND DEPLOYMENT_ID = $2 AND CHECKPOINT_ID = $3`, + } + + // queryDeleteSessionContext removes all of a session's checkpoint contexts. + queryDeleteSessionContext = model.DBQuery{ + ID: "SSO-SESS-AC-03", + Query: `DELETE FROM "SSO_SESSION_CONTEXT" WHERE SESSION_ID = $1 AND DEPLOYMENT_ID = $2`, + } + + // queryListCheckpointsBySessionID returns the checkpoint ids a session has saved. It is the + // existence check the SSO-Check node uses to decide availability without decrypting any context. + queryListCheckpointsBySessionID = model.DBQuery{ + ID: "SSO-SESS-AC-04", + Query: `SELECT CHECKPOINT_ID FROM "SSO_SESSION_CONTEXT" ` + + `WHERE SESSION_ID = $1 AND DEPLOYMENT_ID = $2`, + } + + // queryUpsertParticipant records an application as a participant of a session, refreshing + // LAST_ACTIVE_AT (but preserving FIRST_JOINED_AT) when the application has already joined. The + // ON CONFLICT ... DO UPDATE form is valid in both PostgreSQL and SQLite. + queryUpsertParticipant = model.DBQuery{ + ID: "SSO-SESS-PART-01", + Query: `INSERT INTO "SSO_SESSION_PARTICIPANT" ` + + `(SESSION_ID, DEPLOYMENT_ID, APP_ID, FIRST_JOINED_AT, LAST_ACTIVE_AT) ` + + `VALUES ($1, $2, $3, $4, $5) ` + + `ON CONFLICT (SESSION_ID, DEPLOYMENT_ID, APP_ID) DO UPDATE SET LAST_ACTIVE_AT = excluded.LAST_ACTIVE_AT`, + } + + // queryListParticipantsBySessionID returns the applications that have joined a session, oldest + // first. + queryListParticipantsBySessionID = model.DBQuery{ + ID: "SSO-SESS-PART-02", + Query: `SELECT SESSION_ID, APP_ID, FIRST_JOINED_AT, LAST_ACTIVE_AT FROM "SSO_SESSION_PARTICIPANT" ` + + `WHERE SESSION_ID = $1 AND DEPLOYMENT_ID = $2 ORDER BY FIRST_JOINED_AT`, + } + + // queryDeleteParticipantsBySessionID removes all participants of a session. + queryDeleteParticipantsBySessionID = model.DBQuery{ + ID: "SSO-SESS-PART-03", + Query: `DELETE FROM "SSO_SESSION_PARTICIPANT" WHERE SESSION_ID = $1 AND DEPLOYMENT_ID = $2`, + } +) diff --git a/backend/internal/flow/session/store_test.go b/backend/internal/flow/session/store_test.go new file mode 100644 index 0000000000..20d8e4b395 --- /dev/null +++ b/backend/internal/flow/session/store_test.go @@ -0,0 +1,424 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/thunder-id/thunderid/tests/mocks/database/providermock" +) + +const testDeploymentID = "test-deployment-id" + +type StoreTestSuite struct { + suite.Suite + mockDBProvider *providermock.DBProviderInterfaceMock + mockDBClient *providermock.DBClientInterfaceMock + store *store +} + +func TestStoreTestSuite(t *testing.T) { + suite.Run(t, new(StoreTestSuite)) +} + +func (s *StoreTestSuite) SetupTest() { + s.mockDBProvider = &providermock.DBProviderInterfaceMock{} + s.mockDBClient = &providermock.DBClientInterfaceMock{} + s.store = &store{ + dbProvider: s.mockDBProvider, + deploymentID: testDeploymentID, + } +} + +func (s *StoreTestSuite) sampleSession() Session { + base := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) + return Session{ + SessionID: "sess-1", + SubjectID: "user-1", + FlowID: "flow-1", + FlowVersion: 2, + FlowExecutionID: "exec-1", + HandleID: "handle-abc", + AuthenticatedAt: base, + CreatedAt: base, + LastActiveAt: base, + // IdleExpiresAt left zero on purpose to exercise the nullable path. + AbsoluteExpiresAt: base.Add(8 * time.Hour), + State: StateActive, + Version: 1, + } +} + +func (s *StoreTestSuite) TestNewStore() { + st := newStore(s.mockDBProvider, testDeploymentID) + s.NotNil(st) + s.Implements((*sessionStore)(nil), st) +} + +func (s *StoreTestSuite) TestCreate_Success() { + sess := s.sampleSession() + + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryCreateSession, + sess.SessionID, testDeploymentID, sess.SubjectID, sess.FlowID, sess.FlowVersion, + sess.FlowExecutionID, sess.HandleID, + sess.AuthenticatedAt, sess.CreatedAt, sess.LastActiveAt, + nil, sess.AbsoluteExpiresAt, string(sess.State), sess.Version). + Return(int64(1), nil) + + err := s.store.Create(context.Background(), sess) + + s.NoError(err) + s.mockDBProvider.AssertExpectations(s.T()) + s.mockDBClient.AssertExpectations(s.T()) +} + +func (s *StoreTestSuite) TestCreate_DBError() { + sess := s.sampleSession() + + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryCreateSession, + sess.SessionID, testDeploymentID, sess.SubjectID, sess.FlowID, sess.FlowVersion, + sess.FlowExecutionID, sess.HandleID, + sess.AuthenticatedAt, sess.CreatedAt, sess.LastActiveAt, + nil, sess.AbsoluteExpiresAt, string(sess.State), sess.Version). + Return(int64(0), errors.New("db down")) + + err := s.store.Create(context.Background(), sess) + + s.Error(err) + s.Contains(err.Error(), "failed to create session") +} + +func (s *StoreTestSuite) TestGetByHandle_Hit() { + base := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) + row := map[string]interface{}{ + "session_id": "sess-1", + "subject_id": "user-1", + "flow_id": "flow-1", + "flow_version": int64(2), + "flow_execution_id": "exec-1", + "handle_id": "handle-abc", + "handle_issued_at": base, + "handle_expires_at": base.Add(time.Hour), + "authenticated_at": base, + "created_at": base, + "last_active_at": base, + "idle_expires_at": nil, + "absolute_expires_at": base.Add(8 * time.Hour), + "state": "ACTIVE", + "version": int64(3), + } + + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionByHandle, + "handle-abc", testDeploymentID). + Return([]map[string]interface{}{row}, nil) + + got, err := s.store.GetByHandle(context.Background(), "handle-abc") + + s.NoError(err) + s.Require().NotNil(got) + s.Equal("sess-1", got.SessionID) + s.Equal("user-1", got.SubjectID) + s.Equal("flow-1", got.FlowID) + s.Equal(2, got.FlowVersion) + s.Equal("handle-abc", got.HandleID) + s.Equal(StateActive, got.State) + s.Equal(3, got.Version) + s.True(got.AbsoluteExpiresAt.Equal(base.Add(8 * time.Hour))) + s.True(got.IdleExpiresAt.IsZero()) +} + +func (s *StoreTestSuite) TestGetByHandle_Miss() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionByHandle, + "missing", testDeploymentID). + Return([]map[string]interface{}{}, nil) + + got, err := s.store.GetByHandle(context.Background(), "missing") + + s.NoError(err) + s.Nil(got) +} + +func (s *StoreTestSuite) TestGetByExecutionID_Hit() { + base := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) + row := map[string]interface{}{ + "session_id": "sess-1", + "subject_id": "user-1", + "flow_id": "flow-1", + "flow_version": int64(2), + "flow_execution_id": "exec-1", + "handle_id": "handle-abc", + "authenticated_at": base, + "created_at": base, + "last_active_at": base, + "idle_expires_at": nil, + "absolute_expires_at": base.Add(8 * time.Hour), + "state": "ACTIVE", + "version": int64(1), + } + + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionByExecutionID, + "exec-1", testDeploymentID). + Return([]map[string]interface{}{row}, nil) + + got, err := s.store.GetByExecutionID(context.Background(), "exec-1") + + s.NoError(err) + s.Require().NotNil(got) + s.Equal("sess-1", got.SessionID) + s.Equal("exec-1", got.FlowExecutionID) +} + +func (s *StoreTestSuite) TestGetByExecutionID_Miss() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionByExecutionID, + "missing", testDeploymentID). + Return([]map[string]interface{}{}, nil) + + got, err := s.store.GetByExecutionID(context.Background(), "missing") + + s.NoError(err) + s.Nil(got) +} + +func (s *StoreTestSuite) TestUpdate_Success() { + sess := s.sampleSession() + + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryUpdateSession, + sess.FlowVersion, sess.HandleID, + sess.LastActiveAt, nil, sess.AbsoluteExpiresAt, + string(sess.State), sess.SessionID, testDeploymentID, sess.Version). + Return(int64(1), nil) + + err := s.store.Update(context.Background(), &sess) + + s.NoError(err) + s.Equal(2, sess.Version) // optimistic version bumped in memory + s.mockDBClient.AssertExpectations(s.T()) +} + +func (s *StoreTestSuite) TestUpdate_VersionConflict() { + sess := s.sampleSession() + + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryUpdateSession, + sess.FlowVersion, sess.HandleID, + sess.LastActiveAt, nil, sess.AbsoluteExpiresAt, + string(sess.State), sess.SessionID, testDeploymentID, sess.Version). + Return(int64(0), nil) + + err := s.store.Update(context.Background(), &sess) + + s.ErrorIs(err, errVersionConflict) + s.Equal(1, sess.Version) // version unchanged on conflict +} + +func (s *StoreTestSuite) TestGetByHandle_ClientError() { + s.mockDBProvider.On("GetOperationDBClient").Return(nil, errors.New("no client")) + + got, err := s.store.GetByHandle(context.Background(), "handle-abc") + + s.Error(err) + s.Nil(got) +} + +func (s *StoreTestSuite) TestGetByHandle_QueryError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionByHandle, + "handle-abc", testDeploymentID). + Return(nil, errors.New("query failed")) + + got, err := s.store.GetByHandle(context.Background(), "handle-abc") + + s.Error(err) + s.Nil(got) +} + +// TestBuildSessionFromRow_DriverVariants exercises the []byte / string-time / integer +// forms different drivers return for the same logical columns. +func (s *StoreTestSuite) TestBuildSessionFromRow_DriverVariants() { + row := map[string]interface{}{ + "session_id": []byte("sess-1"), + "subject_id": "user-1", + "flow_id": "flow-1", + "flow_version": int32(2), + "flow_execution_id": "exec-1", + "handle_id": "handle-abc", + "handle_issued_at": "2026-06-16 10:00:00", + "handle_expires_at": "2026-06-16T11:00:00Z", + "authenticated_at": "2026-06-16 10:00:00", + "created_at": "2026-06-16 10:00:00", + "last_active_at": "2026-06-16 10:00:00", + "idle_expires_at": "2026-06-16 10:30:00", + "absolute_expires_at": "2026-06-16 18:00:00", + "state": "ACTIVE", + "version": 3, + } + + got, err := buildSessionFromRow(row) + + s.NoError(err) + s.Require().NotNil(got) + s.Equal("sess-1", got.SessionID) + s.Equal(2, got.FlowVersion) + s.Equal(3, got.Version) + s.False(got.IdleExpiresAt.IsZero()) +} + +func (s *StoreTestSuite) TestBuildSessionFromRow_BadField() { + row := map[string]interface{}{"session_id": 42} + + got, err := buildSessionFromRow(row) + + s.Error(err) + s.Nil(got) +} + +func (s *StoreTestSuite) TestBuildSessionFromRow_BadIntField() { + row := map[string]interface{}{ + "session_id": "sess-1", + "subject_id": "user-1", + "flow_id": "flow-1", + "flow_version": "not-an-int", + } + + got, err := buildSessionFromRow(row) + + s.Error(err) + s.Nil(got) +} + +func (s *StoreTestSuite) TestGetByHandle_MultipleRows() { + row := map[string]interface{}{"session_id": "sess-1"} + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionByHandle, + "handle-abc", testDeploymentID). + Return([]map[string]interface{}{row, row}, nil) + + got, err := s.store.GetByHandle(context.Background(), "handle-abc") + + s.Error(err) + s.Nil(got) + s.Contains(err.Error(), "unexpected number of results") +} + +func (s *StoreTestSuite) TestGetByHandle_BuildError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionByHandle, + "handle-abc", testDeploymentID). + Return([]map[string]interface{}{{"session_id": 42}}, nil) // non-string id fails buildSessionFromRow + + got, err := s.store.GetByHandle(context.Background(), "handle-abc") + + s.Error(err) + s.Nil(got) +} + +func (s *StoreTestSuite) TestGetByExecutionID_QueryError() { + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("QueryContext", context.Background(), queryGetSessionByExecutionID, + "exec-1", testDeploymentID). + Return(nil, errors.New("query failed")) + + got, err := s.store.GetByExecutionID(context.Background(), "exec-1") + + s.Error(err) + s.Nil(got) +} + +func (s *StoreTestSuite) TestUpdate_DBError() { + sess := s.sampleSession() + s.mockDBProvider.On("GetOperationDBClient").Return(s.mockDBClient, nil) + s.mockDBClient.On("ExecuteContext", context.Background(), queryUpdateSession, + sess.FlowVersion, sess.HandleID, sess.LastActiveAt, nil, sess.AbsoluteExpiresAt, + string(sess.State), sess.SessionID, testDeploymentID, sess.Version). + Return(int64(0), errors.New("db down")) + + err := s.store.Update(context.Background(), &sess) + + s.Error(err) + s.Contains(err.Error(), "failed to update session") +} + +func (s *StoreTestSuite) TestBuildSessionFromRow_BadRequiredFields() { + base := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) + valid := func() map[string]interface{} { + return map[string]interface{}{ + "session_id": "sess-1", "subject_id": "user-1", "flow_id": "flow-1", + "flow_version": int64(1), "flow_execution_id": "exec-1", "handle_id": "handle-abc", + "authenticated_at": base, "created_at": base, "last_active_at": base, + "absolute_expires_at": base, "state": "ACTIVE", "version": int64(1), + } + } + // Sanity: a complete row builds cleanly. + _, err := buildSessionFromRow(valid()) + s.Require().NoError(err) + + // Each required string field errors when non-string. + for _, f := range []string{"session_id", "subject_id", "flow_id", "flow_execution_id", "handle_id"} { + row := valid() + row[f] = 42 + _, buildErr := buildSessionFromRow(row) + s.Error(buildErr, "expected error for bad %s", f) + } + // Each required time field errors when non-time. + for _, f := range []string{"authenticated_at", "created_at", "last_active_at"} { + row := valid() + row[f] = 42 + _, buildErr := buildSessionFromRow(row) + s.Error(buildErr, "expected error for bad %s", f) + } + // Version errors when non-numeric. + row := valid() + row["version"] = "nope" + _, err = buildSessionFromRow(row) + s.Error(err) +} + +func (s *StoreTestSuite) TestParseInt_Variants() { + for _, v := range []interface{}{int(1), int32(1), int64(1), float64(1)} { + got, err := parseInt(v, "n") + s.NoError(err) + s.Equal(1, got) + } + _, err := parseInt("nope", "n") + s.Error(err) +} + +func (s *StoreTestSuite) TestParseNullableTime_Variants() { + s.True(parseNullableTime(nil).IsZero()) + s.True(parseNullableTime(42).IsZero()) // unparseable falls back to zero + base := time.Date(2026, 6, 16, 10, 0, 0, 0, time.UTC) + s.Equal(base, parseNullableTime(base)) +} + +func (s *StoreTestSuite) TestParseNullableString_Variants() { + s.Equal("x", parseNullableString([]byte("x"))) + s.Empty(parseNullableString(nil)) +} diff --git a/backend/internal/flow/session/transport.go b/backend/internal/flow/session/transport.go new file mode 100644 index 0000000000..57b2dfa297 --- /dev/null +++ b/backend/internal/flow/session/transport.go @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "time" +) + +// cookieNamePrefix prefixes every per-flow SSO cookie name. +const cookieNamePrefix = "tid_sso_" + +// CookieName derives the per-flow SSO cookie name from the flow ID. Each flow gets its +// own cookie so sessions from different flows do not clobber each other's handle. The +// flow ID is hashed so the raw ID is not exposed in the cookie name and the name stays +// within the cookie-token character set. +func CookieName(flowID string) string { + sum := sha256.Sum256([]byte(flowID)) + return cookieNamePrefix + hex.EncodeToString(sum[:])[:16] +} + +// InboundHandle holds the request-scoped SSO transport inputs read from a transport. It is +// transient: it must never be persisted with the flow context. +type InboundHandle struct { + // Cookies maps every inbound cookie name to its value. The per-flow handle is selected + // from this set by name, because the flow ID is not known when the transport reads the request. + Cookies map[string]string +} + +// HandleFor returns the SSO handle carried for the given flow, or "" when none is present. +func (ih InboundHandle) HandleFor(flowID string) string { + if ih.Cookies == nil { + return "" + } + return ih.Cookies[CookieName(flowID)] +} + +type inboundCtxKey struct{} + +// WithInbound stores the inbound SSO transport inputs on the context for the flow service +// to consume once it has resolved the flow ID. +func WithInbound(ctx context.Context, ih InboundHandle) context.Context { + return context.WithValue(ctx, inboundCtxKey{}, ih) +} + +// InboundFrom retrieves the inbound SSO transport inputs from the context. +func InboundFrom(ctx context.Context) (InboundHandle, bool) { + ih, ok := ctx.Value(inboundCtxKey{}).(InboundHandle) + return ih, ok +} + +// HandleTransport abstracts how the session handle is read from a request and emitted onto a +// response. A cookie is one transport; keeping this behind an interface lets a non-cookie +// transport plug in later. +type HandleTransport interface { + // Read extracts the inbound SSO transport inputs from a request. + Read(r *http.Request) InboundHandle + // Write emits the handle to the response under the given (per-flow) cookie name, valid + // for ttl. + Write(w http.ResponseWriter, cookieName, handle string, ttl time.Duration) + // Clear removes the handle from the response. Seam for logout / session end. + Clear(w http.ResponseWriter, cookieName string) +} + +// cookieTransport carries the handle as an HTTP cookie. +type cookieTransport struct { + secure bool +} + +// NewCookieTransport creates a cookie-backed HandleTransport. secure controls the Secure +// attribute; it should be true behind TLS. +func NewCookieTransport(secure bool) HandleTransport { + return &cookieTransport{secure: secure} +} + +// Read collects all inbound cookies from the request. +func (c *cookieTransport) Read(r *http.Request) InboundHandle { + cookies := make(map[string]string) + for _, ck := range r.Cookies() { + cookies[ck.Name] = ck.Value + } + return InboundHandle{ + Cookies: cookies, + } +} + +// Write sets the per-flow handle cookie on the response. +func (c *cookieTransport) Write(w http.ResponseWriter, cookieName, handle string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: handle, + Path: "/", + MaxAge: int(ttl.Seconds()), + HttpOnly: true, + Secure: c.secure, + // SameSite=Lax suffices for same-site SSO. Cross-site SSO would require + // SameSite=None with Secure. + // TODO(sso): make SameSite configurable for cross-site deployments. + SameSite: http.SameSiteLaxMode, + }) +} + +// Clear expires the per-flow handle cookie on the response. +func (c *cookieTransport) Clear(w http.ResponseWriter, cookieName string) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: c.secure, + SameSite: http.SameSiteLaxMode, + }) +} diff --git a/backend/internal/flow/session/transport_test.go b/backend/internal/flow/session/transport_test.go new file mode 100644 index 0000000000..bf59da46bd --- /dev/null +++ b/backend/internal/flow/session/transport_test.go @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package session + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type TransportTestSuite struct { + suite.Suite +} + +func TestTransportTestSuite(t *testing.T) { + suite.Run(t, new(TransportTestSuite)) +} + +func (s *TransportTestSuite) TestCookieName() { + a := CookieName("flow-1") + b := CookieName("flow-2") + + s.True(strings.HasPrefix(a, cookieNamePrefix)) + s.Equal(a, CookieName("flow-1"), "name must be stable for a flow") + s.NotEqual(a, b, "different flows must get different cookie names") +} + +func (s *TransportTestSuite) TestInboundHandle_HandleFor() { + ih := InboundHandle{Cookies: map[string]string{CookieName("flow-1"): "handle-1"}} + + s.Equal("handle-1", ih.HandleFor("flow-1")) + s.Equal("", ih.HandleFor("flow-2")) + s.Equal("", InboundHandle{}.HandleFor("flow-1")) +} + +func (s *TransportTestSuite) TestInbound_ContextRoundTrip() { + ih := InboundHandle{Cookies: map[string]string{"a": "b"}} + + got, ok := InboundFrom(WithInbound(context.Background(), ih)) + s.True(ok) + s.Equal(ih, got) + + _, ok = InboundFrom(context.Background()) + s.False(ok) +} + +func (s *TransportTestSuite) TestCookieTransport_Read() { + transport := NewCookieTransport(false) + + r := httptest.NewRequest(http.MethodPost, "/flow/execute", nil) + r.AddCookie(&http.Cookie{Name: CookieName("flow-1"), Value: "handle-1"}) + r.AddCookie(&http.Cookie{Name: "unrelated", Value: "x"}) + + ih := transport.Read(r) + + s.Equal("handle-1", ih.HandleFor("flow-1")) + s.Equal("x", ih.Cookies["unrelated"]) +} + +func (s *TransportTestSuite) TestCookieTransport_Write() { + transport := NewCookieTransport(true) + w := httptest.NewRecorder() + + transport.Write(w, CookieName("flow-1"), "handle-1", time.Hour) + + cookies := w.Result().Cookies() + s.Require().Len(cookies, 1) + ck := cookies[0] + s.Equal(CookieName("flow-1"), ck.Name) + s.Equal("handle-1", ck.Value) + s.True(ck.HttpOnly) + s.True(ck.Secure) + s.Equal(http.SameSiteLaxMode, ck.SameSite) + s.Equal(3600, ck.MaxAge) +} + +func (s *TransportTestSuite) TestCookieTransport_Clear() { + transport := NewCookieTransport(false) + w := httptest.NewRecorder() + + transport.Clear(w, CookieName("flow-1")) + + cookies := w.Result().Cookies() + s.Require().Len(cookies, 1) + s.Equal("", cookies[0].Value) + s.True(cookies[0].MaxAge < 0) +} + +// TestCookieTransport_RoundTrip writes a handle then reads it back through a second request, +// proving the transport's write and read agree on the cookie name. +func (s *TransportTestSuite) TestCookieTransport_RoundTrip() { + transport := NewCookieTransport(false) + w := httptest.NewRecorder() + transport.Write(w, CookieName("flow-1"), "handle-xyz", time.Hour) + + r := httptest.NewRequest(http.MethodPost, "/flow/execute", nil) + for _, ck := range w.Result().Cookies() { + r.AddCookie(ck) + } + + ih := transport.Read(r) + s.Equal("handle-xyz", ih.HandleFor("flow-1")) +} diff --git a/backend/internal/oauth/oauth2/authz/service.go b/backend/internal/oauth/oauth2/authz/service.go index dd91c71adc..ece49801c4 100644 --- a/backend/internal/oauth/oauth2/authz/service.go +++ b/backend/internal/oauth/oauth2/authz/service.go @@ -223,6 +223,7 @@ func (as *authorizeService) handleStandardAuthorizationRequest( nonce := msg.RequestQueryParams[oauth2const.RequestParamNonce] acrValues := msg.RequestQueryParams[oauth2const.RequestParamAcrValues] + maxAge := msg.RequestQueryParams[oauth2const.RequestParamMaxAge] dpopJkt := msg.RequestQueryParams[oauth2const.RequestParamDPoPJkt] prompt := msg.RequestQueryParams[oauth2const.RequestParamPrompt] @@ -289,6 +290,7 @@ func (as *authorizeService) handleStandardAuthorizationRequest( ClaimsLocales: claimsLocales, Nonce: nonce, AcrValues: acrValues, + MaxAge: maxAge, DPoPJkt: dpopJkt, Prompt: prompt, } @@ -352,6 +354,9 @@ func (as *authorizeService) initiateFlowAndStoreRequest( if slices.Contains(strings.Fields(oauthParams.Prompt), oauth2const.PromptConsent) { runtimeData[flowcm.RuntimeKeyForceConsentReprompt] = "true" } + if oauthParams.MaxAge != "" { + runtimeData[flowcm.RuntimeKeyMaxAge] = oauthParams.MaxAge + } flowInitCtx := &flowexec.FlowInitContext{ ApplicationID: app.ID, FlowType: string(providers.FlowTypeAuthentication), diff --git a/backend/internal/oauth/oauth2/constants/constants.go b/backend/internal/oauth/oauth2/constants/constants.go index 326031a182..15980af861 100644 --- a/backend/internal/oauth/oauth2/constants/constants.go +++ b/backend/internal/oauth/oauth2/constants/constants.go @@ -62,6 +62,7 @@ const ( RequestParamPrompt string = "prompt" RequestParamRequestURI string = "request_uri" RequestParamAcrValues string = "acr_values" + RequestParamMaxAge string = "max_age" RequestParamDPoPJkt string = "dpop_jkt" RequestParamLoginHint string = "login_hint" RequestParamIDTokenHint string = "id_token_hint" diff --git a/backend/internal/oauth/oauth2/model/parameter.go b/backend/internal/oauth/oauth2/model/parameter.go index 22cbab7c97..05dd17e5da 100644 --- a/backend/internal/oauth/oauth2/model/parameter.go +++ b/backend/internal/oauth/oauth2/model/parameter.go @@ -43,6 +43,7 @@ type OAuthParameters struct { ClaimsLocales string Nonce string AcrValues string + MaxAge string DPoPJkt string Prompt string } diff --git a/backend/internal/serverconfig/constants.go b/backend/internal/serverconfig/constants.go index 9bcceb9eb0..d464ec629f 100644 --- a/backend/internal/serverconfig/constants.go +++ b/backend/internal/serverconfig/constants.go @@ -27,12 +27,15 @@ const ( ConfigNameCORS ConfigName = "cors" // ConfigNameDefaultResourceServer is the configuration key for the default resource server. ConfigNameDefaultResourceServer ConfigName = "defaultResourceServer" + // ConfigNameSession is the configuration key for the SSO session lifetime timeouts. + ConfigNameSession ConfigName = "session" ) // supportedConfigNames lists all the supported server configuration names. var supportedConfigNames = []ConfigName{ ConfigNameCORS, ConfigNameDefaultResourceServer, + ConfigNameSession, } // IsValid reports whether the config name is one of the supported values. diff --git a/backend/internal/system/i18n/core/defaults.go b/backend/internal/system/i18n/core/defaults.go index ece360328c..3053a487b6 100644 --- a/backend/internal/system/i18n/core/defaults.go +++ b/backend/internal/system/i18n/core/defaults.go @@ -1168,6 +1168,8 @@ var defaultMessages = map[string]string{ "flows.executor.errors.http_request_failed_desc": "The HTTP request executor failed to complete the request", "flows.executor.errors.insufficient_permissions": "Insufficient permissions", "flows.executor.errors.insufficient_permissions_desc": "The user does not have sufficient permissions to perform this action", + "flows.executor.errors.interaction_required": "Interaction required", + "flows.executor.errors.interaction_required_desc": "The accumulated authentication assurance does not satisfy the requested acr_values or max_age", "flows.executor.errors.invalid_action": "Invalid action provided", "flows.executor.errors.invalid_action_desc": "The action provided is not valid for the current flow step", "flows.executor.errors.invalid_credentials": "Invalid credentials provided", @@ -1196,6 +1198,8 @@ var defaultMessages = map[string]string{ "flows.executor.errors.magic_link_generation_failed_desc": "Failed to generate the magic link", "flows.executor.errors.max_otp_attempts_reached": "Maximum OTP attempts reached", "flows.executor.errors.max_otp_attempts_reached_desc": "The maximum number of OTP verification attempts has been reached", + "flows.executor.errors.no_live_sso_session": "No live SSO session", + "flows.executor.errors.no_live_sso_session_desc": "No live, compatible SSO session exists for this flow; full authentication is required", "flows.executor.errors.no_registered_passkeys": "No registered passkeys found", "flows.executor.errors.no_registered_passkeys_desc": "No registered passkeys were found for the user", "flows.executor.errors.no_user_types_available": "No user types available", diff --git a/backend/pkg/thunderidengine/providers/model.go b/backend/pkg/thunderidengine/providers/model.go index 23d7b94e5a..968992be58 100644 --- a/backend/pkg/thunderidengine/providers/model.go +++ b/backend/pkg/thunderidengine/providers/model.go @@ -872,6 +872,10 @@ type ExecutorResponse struct { Assertion string `json:"assertion,omitempty"` Error *common.ServiceError `json:"error,omitempty"` AuthUser AuthUser `json:"-"` + // EngineData carries executor output the flow engine consumes internally (for example, a + // transport signal such as a minted session handle). Unlike AdditionalData, it is never + // serialized to the client. + EngineData map[string]string `json:"-"` } // ExecutionPolicy defines behavioral policies for node execution. diff --git a/backend/tests/mocks/flow/sessionmock/HandleTransport_mock.go b/backend/tests/mocks/flow/sessionmock/HandleTransport_mock.go new file mode 100644 index 0000000000..914bcbdf4e --- /dev/null +++ b/backend/tests/mocks/flow/sessionmock/HandleTransport_mock.go @@ -0,0 +1,195 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package sessionmock + +import ( + "net/http" + "time" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/internal/flow/session" +) + +// NewHandleTransportMock creates a new instance of HandleTransportMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewHandleTransportMock(t interface { + mock.TestingT + Cleanup(func()) +}) *HandleTransportMock { + mock := &HandleTransportMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// HandleTransportMock is an autogenerated mock type for the HandleTransport type +type HandleTransportMock struct { + mock.Mock +} + +type HandleTransportMock_Expecter struct { + mock *mock.Mock +} + +func (_m *HandleTransportMock) EXPECT() *HandleTransportMock_Expecter { + return &HandleTransportMock_Expecter{mock: &_m.Mock} +} + +// Clear provides a mock function for the type HandleTransportMock +func (_mock *HandleTransportMock) Clear(w http.ResponseWriter, cookieName string) { + _mock.Called(w, cookieName) + return +} + +// HandleTransportMock_Clear_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clear' +type HandleTransportMock_Clear_Call struct { + *mock.Call +} + +// Clear is a helper method to define mock.On call +// - w http.ResponseWriter +// - cookieName string +func (_e *HandleTransportMock_Expecter) Clear(w interface{}, cookieName interface{}) *HandleTransportMock_Clear_Call { + return &HandleTransportMock_Clear_Call{Call: _e.mock.On("Clear", w, cookieName)} +} + +func (_c *HandleTransportMock_Clear_Call) Run(run func(w http.ResponseWriter, cookieName string)) *HandleTransportMock_Clear_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 http.ResponseWriter + if args[0] != nil { + arg0 = args[0].(http.ResponseWriter) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *HandleTransportMock_Clear_Call) Return() *HandleTransportMock_Clear_Call { + _c.Call.Return() + return _c +} + +func (_c *HandleTransportMock_Clear_Call) RunAndReturn(run func(w http.ResponseWriter, cookieName string)) *HandleTransportMock_Clear_Call { + _c.Run(run) + return _c +} + +// Read provides a mock function for the type HandleTransportMock +func (_mock *HandleTransportMock) Read(r *http.Request) session.InboundHandle { + ret := _mock.Called(r) + + if len(ret) == 0 { + panic("no return value specified for Read") + } + + var r0 session.InboundHandle + if returnFunc, ok := ret.Get(0).(func(*http.Request) session.InboundHandle); ok { + r0 = returnFunc(r) + } else { + r0 = ret.Get(0).(session.InboundHandle) + } + return r0 +} + +// HandleTransportMock_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' +type HandleTransportMock_Read_Call struct { + *mock.Call +} + +// Read is a helper method to define mock.On call +// - r *http.Request +func (_e *HandleTransportMock_Expecter) Read(r interface{}) *HandleTransportMock_Read_Call { + return &HandleTransportMock_Read_Call{Call: _e.mock.On("Read", r)} +} + +func (_c *HandleTransportMock_Read_Call) Run(run func(r *http.Request)) *HandleTransportMock_Read_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *http.Request + if args[0] != nil { + arg0 = args[0].(*http.Request) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *HandleTransportMock_Read_Call) Return(inboundHandle session.InboundHandle) *HandleTransportMock_Read_Call { + _c.Call.Return(inboundHandle) + return _c +} + +func (_c *HandleTransportMock_Read_Call) RunAndReturn(run func(r *http.Request) session.InboundHandle) *HandleTransportMock_Read_Call { + _c.Call.Return(run) + return _c +} + +// Write provides a mock function for the type HandleTransportMock +func (_mock *HandleTransportMock) Write(w http.ResponseWriter, cookieName string, handle string, ttl time.Duration) { + _mock.Called(w, cookieName, handle, ttl) + return +} + +// HandleTransportMock_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' +type HandleTransportMock_Write_Call struct { + *mock.Call +} + +// Write is a helper method to define mock.On call +// - w http.ResponseWriter +// - cookieName string +// - handle string +// - ttl time.Duration +func (_e *HandleTransportMock_Expecter) Write(w interface{}, cookieName interface{}, handle interface{}, ttl interface{}) *HandleTransportMock_Write_Call { + return &HandleTransportMock_Write_Call{Call: _e.mock.On("Write", w, cookieName, handle, ttl)} +} + +func (_c *HandleTransportMock_Write_Call) Run(run func(w http.ResponseWriter, cookieName string, handle string, ttl time.Duration)) *HandleTransportMock_Write_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 http.ResponseWriter + if args[0] != nil { + arg0 = args[0].(http.ResponseWriter) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 time.Duration + if args[3] != nil { + arg3 = args[3].(time.Duration) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *HandleTransportMock_Write_Call) Return() *HandleTransportMock_Write_Call { + _c.Call.Return() + return _c +} + +func (_c *HandleTransportMock_Write_Call) RunAndReturn(run func(w http.ResponseWriter, cookieName string, handle string, ttl time.Duration)) *HandleTransportMock_Write_Call { + _c.Run(run) + return _c +} diff --git a/backend/tests/mocks/flow/sessionmock/Resolver_mock.go b/backend/tests/mocks/flow/sessionmock/Resolver_mock.go new file mode 100644 index 0000000000..a89893f40d --- /dev/null +++ b/backend/tests/mocks/flow/sessionmock/Resolver_mock.go @@ -0,0 +1,114 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package sessionmock + +import ( + "context" + "time" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/internal/flow/session" +) + +// NewResolverMock creates a new instance of ResolverMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewResolverMock(t interface { + mock.TestingT + Cleanup(func()) +}) *ResolverMock { + mock := &ResolverMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// ResolverMock is an autogenerated mock type for the Resolver type +type ResolverMock struct { + mock.Mock +} + +type ResolverMock_Expecter struct { + mock *mock.Mock +} + +func (_m *ResolverMock) EXPECT() *ResolverMock_Expecter { + return &ResolverMock_Expecter{mock: &_m.Mock} +} + +// Resolve provides a mock function for the type ResolverMock +func (_mock *ResolverMock) Resolve(ctx context.Context, handleID string, now time.Time) (*session.Session, error) { + ret := _mock.Called(ctx, handleID, now) + + if len(ret) == 0 { + panic("no return value specified for Resolve") + } + + var r0 *session.Session + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (*session.Session, error)); ok { + return returnFunc(ctx, handleID, now) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) *session.Session); ok { + r0 = returnFunc(ctx, handleID, now) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*session.Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok { + r1 = returnFunc(ctx, handleID, now) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ResolverMock_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' +type ResolverMock_Resolve_Call struct { + *mock.Call +} + +// Resolve is a helper method to define mock.On call +// - ctx context.Context +// - handleID string +// - now time.Time +func (_e *ResolverMock_Expecter) Resolve(ctx interface{}, handleID interface{}, now interface{}) *ResolverMock_Resolve_Call { + return &ResolverMock_Resolve_Call{Call: _e.mock.On("Resolve", ctx, handleID, now)} +} + +func (_c *ResolverMock_Resolve_Call) Run(run func(ctx context.Context, handleID string, now time.Time)) *ResolverMock_Resolve_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 time.Time + if args[2] != nil { + arg2 = args[2].(time.Time) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ResolverMock_Resolve_Call) Return(session1 *session.Session, err error) *ResolverMock_Resolve_Call { + _c.Call.Return(session1, err) + return _c +} + +func (_c *ResolverMock_Resolve_Call) RunAndReturn(run func(ctx context.Context, handleID string, now time.Time) (*session.Session, error)) *ResolverMock_Resolve_Call { + _c.Call.Return(run) + return _c +} diff --git a/backend/tests/mocks/flow/sessionmock/Service_mock.go b/backend/tests/mocks/flow/sessionmock/Service_mock.go new file mode 100644 index 0000000000..26837684fe --- /dev/null +++ b/backend/tests/mocks/flow/sessionmock/Service_mock.go @@ -0,0 +1,352 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package sessionmock + +import ( + "context" + "time" + + mock "github.com/stretchr/testify/mock" + "github.com/thunder-id/thunderid/internal/flow/session" +) + +// NewServiceMock creates a new instance of ServiceMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewServiceMock(t interface { + mock.TestingT + Cleanup(func()) +}) *ServiceMock { + mock := &ServiceMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// ServiceMock is an autogenerated mock type for the Service type +type ServiceMock struct { + mock.Mock +} + +type ServiceMock_Expecter struct { + mock *mock.Mock +} + +func (_m *ServiceMock) EXPECT() *ServiceMock_Expecter { + return &ServiceMock_Expecter{mock: &_m.Mock} +} + +// HasCheckpoint provides a mock function for the type ServiceMock +func (_mock *ServiceMock) HasCheckpoint(ctx context.Context, sessionID string, checkpoint string) (bool, error) { + ret := _mock.Called(ctx, sessionID, checkpoint) + + if len(ret) == 0 { + panic("no return value specified for HasCheckpoint") + } + + var r0 bool + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (bool, error)); ok { + return returnFunc(ctx, sessionID, checkpoint) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) bool); ok { + r0 = returnFunc(ctx, sessionID, checkpoint) + } else { + r0 = ret.Get(0).(bool) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = returnFunc(ctx, sessionID, checkpoint) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ServiceMock_HasCheckpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasCheckpoint' +type ServiceMock_HasCheckpoint_Call struct { + *mock.Call +} + +// HasCheckpoint is a helper method to define mock.On call +// - ctx context.Context +// - sessionID string +// - checkpoint string +func (_e *ServiceMock_Expecter) HasCheckpoint(ctx interface{}, sessionID interface{}, checkpoint interface{}) *ServiceMock_HasCheckpoint_Call { + return &ServiceMock_HasCheckpoint_Call{Call: _e.mock.On("HasCheckpoint", ctx, sessionID, checkpoint)} +} + +func (_c *ServiceMock_HasCheckpoint_Call) Run(run func(ctx context.Context, sessionID string, checkpoint string)) *ServiceMock_HasCheckpoint_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + run( + arg0, + arg1, + arg2, + ) + }) + return _c +} + +func (_c *ServiceMock_HasCheckpoint_Call) Return(b bool, err error) *ServiceMock_HasCheckpoint_Call { + _c.Call.Return(b, err) + return _c +} + +func (_c *ServiceMock_HasCheckpoint_Call) RunAndReturn(run func(ctx context.Context, sessionID string, checkpoint string) (bool, error)) *ServiceMock_HasCheckpoint_Call { + _c.Call.Return(run) + return _c +} + +// LoadCheckpoint provides a mock function for the type ServiceMock +func (_mock *ServiceMock) LoadCheckpoint(ctx context.Context, handle string, checkpoint string, appID string) (*session.Session, *session.SessionContext, error) { + ret := _mock.Called(ctx, handle, checkpoint, appID) + + if len(ret) == 0 { + panic("no return value specified for LoadCheckpoint") + } + + var r0 *session.Session + var r1 *session.SessionContext + var r2 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) (*session.Session, *session.SessionContext, error)); ok { + return returnFunc(ctx, handle, checkpoint, appID) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, string) *session.Session); ok { + r0 = returnFunc(ctx, handle, checkpoint, appID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*session.Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, string) *session.SessionContext); ok { + r1 = returnFunc(ctx, handle, checkpoint, appID) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*session.SessionContext) + } + } + if returnFunc, ok := ret.Get(2).(func(context.Context, string, string, string) error); ok { + r2 = returnFunc(ctx, handle, checkpoint, appID) + } else { + r2 = ret.Error(2) + } + return r0, r1, r2 +} + +// ServiceMock_LoadCheckpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadCheckpoint' +type ServiceMock_LoadCheckpoint_Call struct { + *mock.Call +} + +// LoadCheckpoint is a helper method to define mock.On call +// - ctx context.Context +// - handle string +// - checkpoint string +// - appID string +func (_e *ServiceMock_Expecter) LoadCheckpoint(ctx interface{}, handle interface{}, checkpoint interface{}, appID interface{}) *ServiceMock_LoadCheckpoint_Call { + return &ServiceMock_LoadCheckpoint_Call{Call: _e.mock.On("LoadCheckpoint", ctx, handle, checkpoint, appID)} +} + +func (_c *ServiceMock_LoadCheckpoint_Call) Run(run func(ctx context.Context, handle string, checkpoint string, appID string)) *ServiceMock_LoadCheckpoint_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 string + if args[3] != nil { + arg3 = args[3].(string) + } + run( + arg0, + arg1, + arg2, + arg3, + ) + }) + return _c +} + +func (_c *ServiceMock_LoadCheckpoint_Call) Return(session1 *session.Session, sessionContext *session.SessionContext, err error) *ServiceMock_LoadCheckpoint_Call { + _c.Call.Return(session1, sessionContext, err) + return _c +} + +func (_c *ServiceMock_LoadCheckpoint_Call) RunAndReturn(run func(ctx context.Context, handle string, checkpoint string, appID string) (*session.Session, *session.SessionContext, error)) *ServiceMock_LoadCheckpoint_Call { + _c.Call.Return(run) + return _c +} + +// Resolve provides a mock function for the type ServiceMock +func (_mock *ServiceMock) Resolve(ctx context.Context, handle string, flowID string, flowVersion int, now time.Time) (*session.Session, error) { + ret := _mock.Called(ctx, handle, flowID, flowVersion, now) + + if len(ret) == 0 { + panic("no return value specified for Resolve") + } + + var r0 *session.Session + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int, time.Time) (*session.Session, error)); ok { + return returnFunc(ctx, handle, flowID, flowVersion, now) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, int, time.Time) *session.Session); ok { + r0 = returnFunc(ctx, handle, flowID, flowVersion, now) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*session.Session) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, int, time.Time) error); ok { + r1 = returnFunc(ctx, handle, flowID, flowVersion, now) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ServiceMock_Resolve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resolve' +type ServiceMock_Resolve_Call struct { + *mock.Call +} + +// Resolve is a helper method to define mock.On call +// - ctx context.Context +// - handle string +// - flowID string +// - flowVersion int +// - now time.Time +func (_e *ServiceMock_Expecter) Resolve(ctx interface{}, handle interface{}, flowID interface{}, flowVersion interface{}, now interface{}) *ServiceMock_Resolve_Call { + return &ServiceMock_Resolve_Call{Call: _e.mock.On("Resolve", ctx, handle, flowID, flowVersion, now)} +} + +func (_c *ServiceMock_Resolve_Call) Run(run func(ctx context.Context, handle string, flowID string, flowVersion int, now time.Time)) *ServiceMock_Resolve_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + var arg2 string + if args[2] != nil { + arg2 = args[2].(string) + } + var arg3 int + if args[3] != nil { + arg3 = args[3].(int) + } + var arg4 time.Time + if args[4] != nil { + arg4 = args[4].(time.Time) + } + run( + arg0, + arg1, + arg2, + arg3, + arg4, + ) + }) + return _c +} + +func (_c *ServiceMock_Resolve_Call) Return(session1 *session.Session, err error) *ServiceMock_Resolve_Call { + _c.Call.Return(session1, err) + return _c +} + +func (_c *ServiceMock_Resolve_Call) RunAndReturn(run func(ctx context.Context, handle string, flowID string, flowVersion int, now time.Time) (*session.Session, error)) *ServiceMock_Resolve_Call { + _c.Call.Return(run) + return _c +} + +// SaveCheckpoint provides a mock function for the type ServiceMock +func (_mock *ServiceMock) SaveCheckpoint(ctx context.Context, in session.SaveCheckpointInput) (session.SaveCheckpointResult, error) { + ret := _mock.Called(ctx, in) + + if len(ret) == 0 { + panic("no return value specified for SaveCheckpoint") + } + + var r0 session.SaveCheckpointResult + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, session.SaveCheckpointInput) (session.SaveCheckpointResult, error)); ok { + return returnFunc(ctx, in) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, session.SaveCheckpointInput) session.SaveCheckpointResult); ok { + r0 = returnFunc(ctx, in) + } else { + r0 = ret.Get(0).(session.SaveCheckpointResult) + } + if returnFunc, ok := ret.Get(1).(func(context.Context, session.SaveCheckpointInput) error); ok { + r1 = returnFunc(ctx, in) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// ServiceMock_SaveCheckpoint_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveCheckpoint' +type ServiceMock_SaveCheckpoint_Call struct { + *mock.Call +} + +// SaveCheckpoint is a helper method to define mock.On call +// - ctx context.Context +// - in session.SaveCheckpointInput +func (_e *ServiceMock_Expecter) SaveCheckpoint(ctx interface{}, in interface{}) *ServiceMock_SaveCheckpoint_Call { + return &ServiceMock_SaveCheckpoint_Call{Call: _e.mock.On("SaveCheckpoint", ctx, in)} +} + +func (_c *ServiceMock_SaveCheckpoint_Call) Run(run func(ctx context.Context, in session.SaveCheckpointInput)) *ServiceMock_SaveCheckpoint_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 session.SaveCheckpointInput + if args[1] != nil { + arg1 = args[1].(session.SaveCheckpointInput) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *ServiceMock_SaveCheckpoint_Call) Return(saveCheckpointResult session.SaveCheckpointResult, err error) *ServiceMock_SaveCheckpoint_Call { + _c.Call.Return(saveCheckpointResult, err) + return _c +} + +func (_c *ServiceMock_SaveCheckpoint_Call) RunAndReturn(run func(ctx context.Context, in session.SaveCheckpointInput) (session.SaveCheckpointResult, error)) *ServiceMock_SaveCheckpoint_Call { + _c.Call.Return(run) + return _c +} diff --git a/frontend/apps/console/src/features/flows/components/resources/steps/execution/Execution.tsx b/frontend/apps/console/src/features/flows/components/resources/steps/execution/Execution.tsx index 39663a6f0a..637c5c190b 100644 --- a/frontend/apps/console/src/features/flows/components/resources/steps/execution/Execution.tsx +++ b/frontend/apps/console/src/features/flows/components/resources/steps/execution/Execution.tsx @@ -49,7 +49,15 @@ function Execution({data, resources}: ExecutionPropsInterface): ReactElement | n const executorName = (data?.action as StepAction | undefined)?.executor?.name ?? 'Executor'; // Get display metadata from data (set by resolveStepMetadata) - const displayFromData = data?.display as {label?: string; image?: string; showOnResourcePanel?: boolean} | undefined; + const displayFromData = data?.display as + | { + label?: string; + image?: string; + description?: string; + showOnResourcePanel?: boolean; + outcomes?: {success?: string; failure?: string; incomplete?: string}; + } + | undefined; const hasComponents = useMemo(() => { const components = (data?.components as Element[]) ?? []; @@ -67,7 +75,9 @@ function Execution({data, resources}: ExecutionPropsInterface): ReactElement | n display: { label: displayFromData?.label ?? executorName, image: displayFromData?.image ?? '', + description: displayFromData?.description, showOnResourcePanel: displayFromData?.showOnResourcePanel ?? false, + outcomes: displayFromData?.outcomes, }, }) as Step, [stepId, data, executorName, displayFromData], diff --git a/frontend/apps/console/src/features/flows/components/resources/steps/execution/ExecutionMinimal.tsx b/frontend/apps/console/src/features/flows/components/resources/steps/execution/ExecutionMinimal.tsx index 18066b922d..02d057d9f8 100644 --- a/frontend/apps/console/src/features/flows/components/resources/steps/execution/ExecutionMinimal.tsx +++ b/frontend/apps/console/src/features/flows/components/resources/steps/execution/ExecutionMinimal.tsx @@ -62,6 +62,13 @@ function ExecutionMinimal({resource}: ExecutionMinimalPropsInterface): ReactElem const hasBranchingSupport = stepData?.action && 'onFailure' in stepData.action; const hasIncompleteSupport = stepData?.action && 'onIncomplete' in stepData.action; + // Outcome handles can carry executor-specific labels (e.g. SSO-Check's Available/Unavailable); + // fall back to the generic outcome labels otherwise. + const outcomeLabels = resource.display?.outcomes; + const successLabel = outcomeLabels?.success ?? t('flows:core.executions.handles.success'); + const failureLabel = outcomeLabels?.failure ?? t('flows:core.executions.handles.failure'); + const incompleteLabel = outcomeLabels?.incomplete ?? t('flows:core.executions.handles.incomplete'); + const handleConfigClick = (): void => { if (stepId !== null) { setLastInteractedStepId(stepId); @@ -166,7 +173,7 @@ function ExecutionMinimal({resource}: ExecutionMinimalPropsInterface): ReactElem {/* Success handle - always shown on the right */} {hasBranchingSupport ? ( - + + @@ -193,7 +200,7 @@ function ExecutionMinimal({resource}: ExecutionMinimalPropsInterface): ReactElem )} {/* Incomplete handle - shown at the top when the action supports incomplete (has onIncomplete property) */} {hasIncompleteSupport && ( - + ({ // Mock ExecutionMinimal component vi.mock('../ExecutionMinimal', () => ({ - default: ({resource}: {resource: {display?: {label?: string}}}) => ( -
+ default: ({resource}: {resource: {display?: {label?: string; description?: string; outcomes?: unknown}}}) => ( +
Execution Minimal: {resource?.display?.label}
), @@ -323,6 +328,42 @@ describe('Execution', () => { // Component renders successfully with display.image expect(screen.getByTestId('execution-minimal')).toBeInTheDocument(); }); + + it('should map display.description into the resource display', () => { + render( + , + ); + + expect(screen.getByTestId('execution-minimal')).toHaveAttribute( + 'data-description', + 'Can the following authentication be skipped by reusing the existing session?', + ); + }); + + it('should map display.outcomes into the resource display', () => { + render( + , + ); + + expect(screen.getByTestId('execution-minimal')).toHaveAttribute( + 'data-outcomes', + JSON.stringify({success: 'Skip to', failure: 'Authenticate'}), + ); + }); }); describe('Memoization', () => { diff --git a/frontend/apps/console/src/features/flows/components/resources/steps/execution/__tests__/ExecutionMinimal.test.tsx b/frontend/apps/console/src/features/flows/components/resources/steps/execution/__tests__/ExecutionMinimal.test.tsx index aaa587cf86..6dbf8b3926 100644 --- a/frontend/apps/console/src/features/flows/components/resources/steps/execution/__tests__/ExecutionMinimal.test.tsx +++ b/frontend/apps/console/src/features/flows/components/resources/steps/execution/__tests__/ExecutionMinimal.test.tsx @@ -215,6 +215,28 @@ describe('ExecutionMinimal', () => { expect(screen.getByTestId('handle-source-execution-handle-failure')).toBeInTheDocument(); }); + it('should use custom outcome labels from display.outcomes', () => { + const resource = createMockResource({ + display: { + label: 'SSO Check', + image: '', + showOnResourcePanel: true, + outcomes: {success: 'Available', failure: 'Unavailable'}, + }, + data: { + action: { + executor: {name: 'SSOCheckExecutor'}, + onSuccess: '', + onFailure: '', + }, + }, + }); + render(); + + expect(screen.getByLabelText('Available')).toBeInTheDocument(); + expect(screen.getByLabelText('Unavailable')).toBeInTheDocument(); + }); + it('should wrap handles in tooltips when both handles are present', () => { const resource = createMockResource({ data: { diff --git a/frontend/apps/console/src/features/flows/components/resources/steps/execution/execution-factory/ExecutionFactory.tsx b/frontend/apps/console/src/features/flows/components/resources/steps/execution/execution-factory/ExecutionFactory.tsx index 046e52eff5..d93c33ba18 100644 --- a/frontend/apps/console/src/features/flows/components/resources/steps/execution/execution-factory/ExecutionFactory.tsx +++ b/frontend/apps/console/src/features/flows/components/resources/steps/execution/execution-factory/ExecutionFactory.tsx @@ -48,6 +48,8 @@ function ExecutionFactory({resource}: ExecutionFactoryPropsInterface): ReactElem const displayImage = resource.display?.image; // display.label contains the action/mode (e.g., "Passkey Challenge", "Send SMS OTP") const displayLabel = resource.display?.label; + // Optional descriptive text shown inside the node body (e.g. what the executor checks). + const displayDescription = resource.display?.description; // Google, GitHub, and SMS OTP executors have special validation logic if (executorName === ExecutionTypes.GoogleFederation) { @@ -62,22 +64,34 @@ function ExecutionFactory({resource}: ExecutionFactoryPropsInterface): ReactElem // The header shows the executor name, the content shows the action/mode if (displayImage) { return ( - - {`${displayLabel - {displayLabel ?? t('flows:core.executions.names.default')} + + + {`${displayLabel + {displayLabel ?? t('flows:core.executions.names.default')} + + {displayDescription && ( + + {displayDescription} + + )} ); } // Fallback for executors without display image return ( - + {displayLabel ?? t('flows:core.executions.names.default')} + {displayDescription && ( + + {displayDescription} + + )} ); } diff --git a/frontend/apps/console/src/features/flows/components/resources/steps/execution/execution-factory/__tests__/ExecutionFactory.test.tsx b/frontend/apps/console/src/features/flows/components/resources/steps/execution/execution-factory/__tests__/ExecutionFactory.test.tsx index a477e142d0..7a2efa8047 100644 --- a/frontend/apps/console/src/features/flows/components/resources/steps/execution/execution-factory/__tests__/ExecutionFactory.test.tsx +++ b/frontend/apps/console/src/features/flows/components/resources/steps/execution/execution-factory/__tests__/ExecutionFactory.test.tsx @@ -197,6 +197,30 @@ describe('ExecutionFactory', () => { expect(screen.getByText('Custom Executor')).toBeInTheDocument(); }); + it('should render the description inside the node body when provided', () => { + const resource = createMockResource({ + display: { + label: 'Check SSO Session', + description: 'Can the following authentication be skipped by reusing the existing session?', + image: 'assets/images/icons/magnifying-glass.svg', + showOnResourcePanel: true, + }, + data: { + action: { + executor: { + name: 'SSOCheckExecutor', + }, + }, + }, + }); + render(); + + expect(screen.getByText('Check SSO Session')).toBeInTheDocument(); + expect( + screen.getByText('Can the following authentication be skipped by reusing the existing session?'), + ).toBeInTheDocument(); + }); + it('should use default alt text when displayLabel is undefined', () => { const resource = createMockResource({ display: { diff --git a/frontend/apps/console/src/features/flows/data/templates.json b/frontend/apps/console/src/features/flows/data/templates.json index 9343ccd7dc..ef24c13c95 100644 --- a/frontend/apps/console/src/features/flows/data/templates.json +++ b/frontend/apps/console/src/features/flows/data/templates.json @@ -315,6 +315,263 @@ }, "nodes": [] }, + { + "resourceType": "TEMPLATE", + "category": "PASSWORD", + "type": "BASIC_SSO", + "flowType": "AUTHENTICATION", + "display": { + "label": "Basic with SSO", + "description": "Username and password authentication with single sign-on", + "image": "assets/images/icons/lock.svg", + "showOnResourcePanel": true + }, + "config": { + "name": "Basic Sign-in Flow with SSO", + "handle": "basic-sso-signin-flow", + "nodes": [ + { + "id": "start", + "type": "START", + "layout": { + "size": { + "width": 101, + "height": 34 + }, + "position": { + "x": 29, + "y": 434 + } + }, + "onSuccess": "sso_check" + }, + { + "id": "sso_check", + "type": "TASK_EXECUTION", + "layout": { + "size": { + "width": 517, + "height": 132 + }, + "position": { + "x": 230, + "y": 386 + } + }, + "properties": { + "checkpointRef": "session" + }, + "executor": { + "name": "SSOCheckExecutor" + }, + "onSuccess": "session", + "onFailure": "prompt_credentials" + }, + { + "id": "prompt_credentials", + "type": "PROMPT", + "layout": { + "size": { + "width": 350, + "height": 683 + }, + "position": { + "x": 605, + "y": 666 + } + }, + "meta": { + "components": [ + { + "alt": "{{ t(signin:images.app_logo.alt) }}", + "category": "DISPLAY", + "height": "60", + "id": "image", + "resourceType": "ELEMENT", + "src": "{{ meta(application.logoUrl) }}", + "type": "IMAGE", + "width": "" + }, + { + "align": "center", + "category": "DISPLAY", + "id": "text_001", + "label": "{{ t(signin:forms.credentials.title) }}", + "resourceType": "ELEMENT", + "type": "TEXT", + "variant": "HEADING_1" + }, + { + "category": "BLOCK", + "components": [ + { + "category": "FIELD", + "hint": "", + "id": "input_001", + "inputType": "text", + "label": "{{ t(signin:forms.credentials.fields.username.label) }}", + "placeholder": "{{ t(signin:forms.credentials.fields.username.placeholder) }}", + "ref": "username", + "required": true, + "resourceType": "ELEMENT", + "type": "TEXT_INPUT" + }, + { + "category": "FIELD", + "hint": "", + "id": "input_002", + "inputType": "text", + "label": "{{ t(signin:forms.credentials.fields.password.label) }}", + "placeholder": "{{ t(signin:forms.credentials.fields.password.placeholder) }}", + "ref": "password", + "required": true, + "resourceType": "ELEMENT", + "type": "PASSWORD_INPUT" + }, + { + "category": "ACTION", + "eventType": "SUBMIT", + "id": "action_001", + "label": "{{ t(signin:forms.credentials.actions.submit.label) }}", + "resourceType": "ELEMENT", + "type": "ACTION", + "variant": "PRIMARY" + } + ], + "id": "block_001", + "resourceType": "ELEMENT", + "type": "BLOCK" + } + ] + }, + "prompts": [ + { + "inputs": [ + { + "ref": "input_001", + "type": "TEXT_INPUT", + "identifier": "username", + "required": true + }, + { + "ref": "input_002", + "type": "PASSWORD_INPUT", + "identifier": "password", + "required": true + } + ], + "action": { + "ref": "action_001", + "nextNode": "credentials_auth" + } + } + ] + }, + { + "id": "credentials_auth", + "type": "TASK_EXECUTION", + "layout": { + "size": { + "width": 217, + "height": 113 + }, + "position": { + "x": 1115, + "y": 658 + } + }, + "executor": { + "name": "CredentialsAuthExecutor", + "inputs": [ + { + "ref": "input_001", + "type": "TEXT_INPUT", + "identifier": "username", + "required": true + }, + { + "ref": "input_002", + "type": "PASSWORD_INPUT", + "identifier": "password", + "required": true + } + ] + }, + "onSuccess": "session", + "onIncomplete": "prompt_credentials" + }, + { + "id": "session", + "type": "TASK_EXECUTION", + "layout": { + "size": { + "width": 210, + "height": 113 + }, + "position": { + "x": 1470, + "y": 442 + } + }, + "executor": { + "name": "SessionExecutor" + }, + "onSuccess": "authorization_check" + }, + { + "id": "authorization_check", + "type": "TASK_EXECUTION", + "layout": { + "size": { + "width": 200, + "height": 113 + }, + "position": { + "x": 1820, + "y": 442 + } + }, + "executor": { + "name": "AuthorizationExecutor" + }, + "onSuccess": "auth_assert" + }, + { + "id": "auth_assert", + "type": "TASK_EXECUTION", + "layout": { + "size": { + "width": 244, + "height": 113 + }, + "position": { + "x": 2120, + "y": 442 + } + }, + "executor": { + "name": "AuthAssertExecutor" + }, + "onSuccess": "end" + }, + { + "id": "end", + "type": "END", + "layout": { + "size": { + "width": 85, + "height": 34 + }, + "position": { + "x": 2500, + "y": 482 + } + } + } + ] + }, + "nodes": [] + }, { "resourceType": "TEMPLATE", "category": "PASSWORDLESS", diff --git a/frontend/apps/console/src/features/flows/models/base.ts b/frontend/apps/console/src/features/flows/models/base.ts index 919c7c3835..9b0be54a6b 100644 --- a/frontend/apps/console/src/features/flows/models/base.ts +++ b/frontend/apps/console/src/features/flows/models/base.ts @@ -106,6 +106,15 @@ export interface BaseDisplay { * Should the component be shown on the resource panel. */ showOnResourcePanel: boolean; + /** + * Optional custom labels for an execution node's outcome handles (success / failure / + * incomplete). When omitted, generic outcome labels are used. + */ + outcomes?: { + success?: string; + failure?: string; + incomplete?: string; + }; } /** diff --git a/frontend/apps/console/src/features/flows/utils/flowToCanvasTransformer.ts b/frontend/apps/console/src/features/flows/utils/flowToCanvasTransformer.ts index ea6012c26b..b6a8c34026 100644 --- a/frontend/apps/console/src/features/flows/utils/flowToCanvasTransformer.ts +++ b/frontend/apps/console/src/features/flows/utils/flowToCanvasTransformer.ts @@ -272,7 +272,10 @@ function transformNodeToCanvas(apiNode: FlowNode): CanvasNode { type: 'EXECUTOR', executor: apiNode.executor, onSuccess: apiNode.onSuccess, - onFailure: apiNode.onFailure, + // Only carry the branching outcomes the node actually declares. Their presence drives + // the failure/incomplete output handles, so a node without them stays single-outcome + // rather than showing a dangling handle. + ...(apiNode.onFailure !== undefined ? {onFailure: apiNode.onFailure} : {}), ...(apiNode.onIncomplete !== undefined ? {onIncomplete: apiNode.onIncomplete} : {}), }, }; diff --git a/frontend/apps/console/src/features/login-flow/data/executors.json b/frontend/apps/console/src/features/login-flow/data/executors.json index 1ebd72cc8a..73867cf729 100644 --- a/frontend/apps/console/src/features/login-flow/data/executors.json +++ b/frontend/apps/console/src/features/login-flow/data/executors.json @@ -845,5 +845,51 @@ "allowAuthenticationWithoutLocalUser": true } } + }, + { + "resourceType": "STEP", + "category": "EXECUTOR", + "type": "TASK_EXECUTION", + "display": { + "header": "SSO Check Executor", + "label": "Check SSO Session", + "description": "Can the following authentication be skipped by reusing the existing session?", + "image": "assets/images/icons/magnifying-glass.svg", + "showOnResourcePanel": true, + "outcomes": { + "success": "Skip to", + "failure": "Authenticate" + } + }, + "data": { + "action": { + "type": "EXECUTOR", + "executor": { + "name": "SSOCheckExecutor" + }, + "onSuccess": "", + "onFailure": "" + } + } + }, + { + "resourceType": "STEP", + "category": "EXECUTOR", + "type": "TASK_EXECUTION", + "display": { + "header": "Session Executor", + "label": "Save / Load Session", + "image": "assets/images/icons/link.svg", + "showOnResourcePanel": true + }, + "data": { + "action": { + "type": "EXECUTOR", + "executor": { + "name": "SessionExecutor" + }, + "onSuccess": "" + } + } } ]