Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion backend/.mockery.private.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion backend/.mockery.public.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 49 additions & 16 deletions backend/cmd/server/servicemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -344,6 +363,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa
GithubSvc: githubAuthnService,
GoogleSvc: googleAuthnService,
OpenID4VPVerifierSvc: openid4vpSvc,
SessionService: sessionService,
},
interceptor.InterceptorDependencies{},
flowConfig,
Expand Down Expand Up @@ -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)

Expand All @@ -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))
}
Expand Down Expand Up @@ -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()
Expand Down
56 changes: 56 additions & 0 deletions backend/dbscripts/operationdb/postgres.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
56 changes: 56 additions & 0 deletions backend/dbscripts/operationdb/sqlite.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
33 changes: 33 additions & 0 deletions backend/internal/flow/common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
32 changes: 17 additions & 15 deletions backend/internal/flow/common/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion backend/internal/flow/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,27 @@
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"
)

// 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,
}
}
Loading
Loading