From 0eab1a6438400f89d7021dc740a7f41c51b2d750 Mon Sep 17 00:00:00 2001 From: Maduranga Siriwardena Date: Thu, 9 Jul 2026 20:12:22 +0530 Subject: [PATCH] Add flow-centric browser SSO Introduce a session.Service that owns resolve/checkpoint orchestration and transactions, hiding the individual stores behind unexported constructors. Build it in session.Initialize and inject it into the executor tree so the flowexec engine and service carry no SSO initialization logic. Add an engine-only EngineData channel on the executor/node responses so the session handle reaches the engine without leaking to the client. Inject the SSO cookie Secure flag and session timeouts into flowexec through flowconfig.Config instead of reading the server runtime inside the package, and move the SSO session lifetime configuration into the server-config API as a new "session" section. Refs #3779 --- backend/.mockery.private.yml | 10 +- backend/.mockery.public.yml | 13 +- backend/cmd/server/servicemanager.go | 65 +- backend/dbscripts/operationdb/postgres.sql | 56 ++ backend/dbscripts/operationdb/sqlite.sql | 56 ++ backend/internal/flow/common/constants.go | 33 + backend/internal/flow/common/model.go | 32 +- backend/internal/flow/config/config.go | 10 +- backend/internal/flow/config/config_test.go | 7 + .../internal/flow/core/task_execution_node.go | 1 + .../executor/auth_assert_assurance_test.go | 124 +++ .../flow/executor/auth_assert_executor.go | 55 ++ backend/internal/flow/executor/constants.go | 2 + .../internal/flow/executor/error_constants.go | 36 + backend/internal/flow/executor/register.go | 9 + .../flow/executor/session_executor.go | 308 ++++++++ .../flow/executor/session_executor_test.go | 395 ++++++++++ .../flow/executor/sso_check_executor.go | 122 +++ .../flow/executor/sso_check_executor_test.go | 200 +++++ backend/internal/flow/flowexec/engine.go | 28 +- backend/internal/flow/flowexec/engine_test.go | 1 + backend/internal/flow/flowexec/handler.go | 25 +- .../internal/flow/flowexec/handler_test.go | 80 +- backend/internal/flow/flowexec/init.go | 8 +- backend/internal/flow/flowexec/model.go | 13 + backend/internal/flow/flowexec/service.go | 64 ++ .../flow/flowexec/service_sso_test.go | 133 ++++ .../flow/graphbuilder/graph_builder_test.go | 50 ++ .../flow/graphbuilder/testdata/sso_flow.json | 127 +++ .../flow/session/HandleTransport_mock_test.go | 194 +++++ .../flow/session/Resolver_mock_test.go | 113 +++ .../flow/session/Service_mock_test.go | 351 +++++++++ backend/internal/flow/session/config.go | 86 +++ backend/internal/flow/session/config_test.go | 76 ++ .../internal/flow/session/error_constants.go | 31 + backend/internal/flow/session/init.go | 54 ++ backend/internal/flow/session/interface.go | 58 ++ backend/internal/flow/session/model.go | 121 +++ .../flow/session/participant_store.go | 100 +++ .../flow/session/participant_store_test.go | 195 +++++ backend/internal/flow/session/resolver.go | 72 ++ .../internal/flow/session/resolver_test.go | 147 ++++ backend/internal/flow/session/service.go | 306 ++++++++ backend/internal/flow/session/service_test.go | 351 +++++++++ .../flow/session/sessionStore_mock_test.go | 726 ++++++++++++++++++ .../internal/flow/session/session_context.go | 97 +++ .../flow/session/session_context_store.go | 141 ++++ .../session/session_context_store_test.go | 281 +++++++ backend/internal/flow/session/state.go | 65 ++ backend/internal/flow/session/state_test.go | 128 +++ backend/internal/flow/session/store.go | 260 +++++++ .../internal/flow/session/store_constants.go | 125 +++ backend/internal/flow/session/store_test.go | 424 ++++++++++ backend/internal/flow/session/transport.go | 133 ++++ .../internal/flow/session/transport_test.go | 124 +++ .../internal/oauth/oauth2/authz/service.go | 5 + .../oauth/oauth2/constants/constants.go | 1 + .../internal/oauth/oauth2/model/parameter.go | 1 + backend/internal/serverconfig/constants.go | 3 + backend/internal/system/i18n/core/defaults.go | 4 + .../pkg/thunderidengine/providers/model.go | 4 + .../flow/sessionmock/HandleTransport_mock.go | 195 +++++ .../mocks/flow/sessionmock/Resolver_mock.go | 114 +++ .../mocks/flow/sessionmock/Service_mock.go | 352 +++++++++ .../resources/steps/execution/Execution.tsx | 12 +- .../steps/execution/ExecutionMinimal.tsx | 13 +- .../execution/__tests__/Execution.test.tsx | 45 +- .../__tests__/ExecutionMinimal.test.tsx | 22 + .../execution-factory/ExecutionFactory.tsx | 32 +- .../__tests__/ExecutionFactory.test.tsx | 24 + .../src/features/flows/data/templates.json | 257 +++++++ .../console/src/features/flows/models/base.ts | 9 + .../flows/utils/flowToCanvasTransformer.ts | 5 +- .../features/login-flow/data/executors.json | 46 ++ 74 files changed, 7907 insertions(+), 59 deletions(-) create mode 100644 backend/internal/flow/executor/auth_assert_assurance_test.go create mode 100644 backend/internal/flow/executor/session_executor.go create mode 100644 backend/internal/flow/executor/session_executor_test.go create mode 100644 backend/internal/flow/executor/sso_check_executor.go create mode 100644 backend/internal/flow/executor/sso_check_executor_test.go create mode 100644 backend/internal/flow/flowexec/service_sso_test.go create mode 100644 backend/internal/flow/graphbuilder/testdata/sso_flow.json create mode 100644 backend/internal/flow/session/HandleTransport_mock_test.go create mode 100644 backend/internal/flow/session/Resolver_mock_test.go create mode 100644 backend/internal/flow/session/Service_mock_test.go create mode 100644 backend/internal/flow/session/config.go create mode 100644 backend/internal/flow/session/config_test.go create mode 100644 backend/internal/flow/session/error_constants.go create mode 100644 backend/internal/flow/session/init.go create mode 100644 backend/internal/flow/session/interface.go create mode 100644 backend/internal/flow/session/model.go create mode 100644 backend/internal/flow/session/participant_store.go create mode 100644 backend/internal/flow/session/participant_store_test.go create mode 100644 backend/internal/flow/session/resolver.go create mode 100644 backend/internal/flow/session/resolver_test.go create mode 100644 backend/internal/flow/session/service.go create mode 100644 backend/internal/flow/session/service_test.go create mode 100644 backend/internal/flow/session/sessionStore_mock_test.go create mode 100644 backend/internal/flow/session/session_context.go create mode 100644 backend/internal/flow/session/session_context_store.go create mode 100644 backend/internal/flow/session/session_context_store_test.go create mode 100644 backend/internal/flow/session/state.go create mode 100644 backend/internal/flow/session/state_test.go create mode 100644 backend/internal/flow/session/store.go create mode 100644 backend/internal/flow/session/store_constants.go create mode 100644 backend/internal/flow/session/store_test.go create mode 100644 backend/internal/flow/session/transport.go create mode 100644 backend/internal/flow/session/transport_test.go create mode 100644 backend/tests/mocks/flow/sessionmock/HandleTransport_mock.go create mode 100644 backend/tests/mocks/flow/sessionmock/Resolver_mock.go create mode 100644 backend/tests/mocks/flow/sessionmock/Service_mock.go 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": "" + } + } } ]